diff --git a/client/src/App.jsx b/client/src/App.jsx index a627509..1750361 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -42,6 +42,7 @@ import UsersAdmin from './routes/admin/views/UsersAdmin.jsx' import UserDetail from './routes/admin/views/UserDetail.jsx' import InvitesAdmin from './routes/admin/views/InvitesAdmin.jsx' import ModulesAdmin from './routes/admin/views/ModulesAdmin.jsx' +import TeamsAdmin from './routes/admin/views/TeamsAdmin.jsx' import AccountAdmin from './routes/admin/views/AccountAdmin.jsx' import Moderation from './routes/admin/views/Moderation.jsx' import ModerationUser from './routes/admin/views/ModerationUser.jsx' @@ -174,6 +175,10 @@ export default function App() { the volume in the first place. Declared here with the rest of core's routes, above the module-supplied ones below. */} } /> + {/* Staff-wide, like the moderation queues: the gate on the three + actions that publish a game-written name is applied per request + on the server, from the caller's live role (TEAMS.md 2.9). */} + } /> } /> {/* Installed modules' admin pages, at /admin//…, already inside RequireAuth + AdminLayout. A module cannot supply its own auth diff --git a/client/src/api/client.js b/client/src/api/client.js index b21db5a..64359c4 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -247,6 +247,29 @@ export const api = { setModuleSources: (hosts) => req('/admin/modules/sources', { method: 'PUT', body: { hosts } }), restartServer: () => req('/admin/modules/restart', { method: 'POST' }), + // Teams (docs/website/TEAMS.md §2.11). Three of these mean something + // different depending on who calls them: for a moderator, unhide and + // setTeamDisplayName file a request and the response says `pending: true`. + // The caller does not choose — the server decides from the live role — so + // there is deliberately no "asRequest" argument to get wrong. + listTeams: () => req('/admin/teams'), + getTeam: (id) => req(`/admin/teams/${id}`), + resyncTeams: () => req('/admin/teams/resync', { method: 'POST' }), + archiveTeam: (id, reason) => req(`/admin/teams/${id}/archive`, { method: 'POST', body: { reason } }), + teamGrants: (id) => req(`/admin/teams/${id}/grants`), + hideTeam: (id, reason) => req(`/admin/teams/${id}/hide`, { method: 'POST', body: { reason } }), + unhideTeam: (id, reason) => req(`/admin/teams/${id}/unhide`, { method: 'POST', body: { reason } }), + setTeamDisplayName: (id, displayName, reason) => + req(`/admin/teams/${id}/display-name`, { method: 'POST', body: { displayName, reason } }), + setTeamLeaderOverride: (id, body) => + req(`/admin/teams/${id}/leader-override`, { method: 'POST', body }), + clearTeamLeaderOverride: (id, memberKey) => + req(`/admin/teams/${id}/leader-override/${encodeURIComponent(memberKey)}`, { method: 'DELETE' }), + teamReviewQueue: () => req('/admin/teams/review'), + teamRequests: (status) => req(`/admin/teams/requests${status ? `?status=${status}` : ''}`), + decideTeamRequest: (id, status, note) => + req(`/admin/teams/requests/${id}/decide`, { method: 'POST', body: { status, note } }), + // ----- moderation dashboard (admin + moderator) ----- modSummary: () => req('/admin/moderation/stats/summary'), modRecent: (params = {}) => { diff --git a/client/src/lib/teamAdmin.js b/client/src/lib/teamAdmin.js new file mode 100644 index 0000000..7c3c34f --- /dev/null +++ b/client/src/lib/teamAdmin.js @@ -0,0 +1,140 @@ +// What Admin → Teams SAYS, separated from how it renders (docs/website/TEAMS.md +// §2.4, §2.8, §2.9). +// +// Plain JS with tests, following lib/moduleAdmin.js. The reason it is worth +// splitting here specifically: this screen's job is to tell an operator the +// difference between "the shard has no Teams" and "core has not been able to ask +// for two hours", and those two produce almost the same page. Getting that +// wording right is logic, not markup. + +/** Tones the screen uses. Names, not colours — the view maps them. */ +export const TONE = { ok: 'ok', warn: 'warn', bad: 'bad', idle: 'idle' } + +/** + * How to describe the projection's freshness. + * + * The four states are genuinely different and an operator needs to tell them + * apart: + * + * - no provider registered — nothing to sync, and not a fault; + * - never synced — core has an empty projection it has never confirmed, which + * must NOT read as "there are no Teams"; + * - stale — the projection is real but old, and the reason is usually in + * `lastError`; + * - current. + */ +export function freshnessOf(sync = {}) { + if (!sync.configured) { + return { tone: TONE.idle, label: 'No Team provider', detail: 'No installed module supplies Teams.' } + } + if (!sync.lastSyncAt) { + return { + tone: TONE.bad, + label: 'Never synced', + detail: 'Core has never had an answer it could trust. What is shown below is not a confirmed empty shard.', + } + } + if (sync.stale) { + return { + tone: TONE.warn, + label: 'Stale', + detail: `Last confirmed ${ago(sync.lastSyncAt)}. Rosters below may be out of date.`, + } + } + return { tone: TONE.ok, label: 'Current', detail: `Last confirmed ${ago(sync.lastSyncAt)}.` } +} + +/** + * A short, human age. Deliberately coarse: this exists so a sentence reads + * "confirmed 14 minutes ago", and second-level precision would be false comfort + * about a projection whose interval is fifteen minutes. + */ +export function ago(value) { + if (!value) return 'never' + const seconds = Math.max(0, Math.round((Date.now() - new Date(value).getTime()) / 1000)) + if (seconds < 90) return 'just now' + const minutes = Math.round(seconds / 60) + if (minutes < 60) return `${minutes} minutes ago` + const hours = Math.round(minutes / 60) + if (hours < 48) return `${hours} hour${hours === 1 ? '' : 's'} ago` + return `${Math.round(hours / 24)} days ago` +} + +/** The status pill for one Team row. */ +export function statusOf(team = {}) { + if (team.status === 'archived') { + return { tone: TONE.idle, label: team.archivedReason === 'renamed' ? 'Renamed' : 'Archived' } + } + if (team.hidden && team.hiddenReason === 'reserved_name') { + return { tone: TONE.bad, label: 'Hidden — reserved name' } + } + if (team.hidden) return { tone: TONE.warn, label: 'Hidden by staff' } + return { tone: TONE.ok, label: 'Public' } +} + +/** + * What a staff member is told will happen when they press the button. + * + * The gate is decided server-side from the caller's live role, so this only + * describes it. Saying "Request" to a moderator and "Apply" to an admin is what + * stops the pending result being a surprise. + */ +export function gateLabelFor(role, verb) { + return role === 'admin' ? verb : `Request ${verb.toLowerCase()}` +} + +/** The three gated actions, for the note under the buttons. */ +export const GATED_NOTE = + 'Publishing a game-written name needs an admin: a moderator’s un-hide or display-name change ' + + 'is filed for approval. Hiding is not gated — suppression is always safe.' + +/** A one-line description of a queued request, for the approval queue. */ +export function describeRequest(request = {}) { + const payload = parsePayload(request.payload) + const who = request.requested_username || 'a deleted user' + switch (request.action) { + case 'unhide': + return `${who} asks to publish “${request.team_name}”` + case 'display_name_override': + return `${who} asks to display “${request.team_name}” as “${payload.displayName || ''}”` + case 'clear_display_name_override': + return `${who} asks to clear the display name on “${request.team_name}”` + default: + return `${who} asks for “${request.action}” on “${request.team_name}”` + } +} + +/** + * The payload may arrive parsed or as a JSON string depending on the driver, so + * this normalises rather than assuming either. The server has the same note. + */ +export function parsePayload(payload) { + if (payload == null) return {} + if (typeof payload === 'object') return payload + try { + return JSON.parse(payload) + } catch { + return {} + } +} + +/** + * How a member's leadership should read. + * + * An override is shown AS an override rather than folded into the answer: staff + * looking at a roster need to see that a decision was made, not a fact that looks + * like the game's. + */ +export function leadershipOf(member = {}) { + if (!member.leaderOverride) { + return { isLeader: Boolean(member.isLeader), overridden: false, note: null } + } + const granted = member.leaderOverride.effect === 'grant' + return { + isLeader: granted, + overridden: true, + note: `${granted ? 'Granted' : 'Denied'} by ${member.leaderOverride.by || 'a deleted user'}` + + `${member.leaderOverride.reason ? ` — ${member.leaderOverride.reason}` : ''}` + + ` (the game says ${member.isLeaderSynced ? 'leader' : 'not a leader'})`, + } +} diff --git a/client/src/modules/version.js b/client/src/modules/version.js index 73934de..c0c833a 100644 --- a/client/src/modules/version.js +++ b/client/src/modules/version.js @@ -11,6 +11,13 @@ // that the two files can drift, so a test asserts they agree // (client/test/moduleRegistry.test.js) rather than trusting a bump to remember // both. +// 1.6.0 — the Team surface (docs/website/TEAMS.md Part 11). Nothing on this half +// changed yet: the two client additions the version covers are the `team.overview` +// and `team.member.row` slots, and a slot can only be declared by the page that +// hosts it, which lands with the Team pages in phase 3. This file bumps anyway, +// for the reason at the top — the two halves state ONE version, and a module +// declares one `coreApi` range against both. +// // 1.5.0 — `PublicLayout` takes an optional `shell` prop ('narrow' | 'mid' | // 'wide') that renders the `shell-… page-body` wrapper core's own pages write by // hand. Additive: omitting it is 1.4.0's behaviour, so §3.4's "changing a kit @@ -38,4 +45,4 @@ // but the two halves state ONE version: a module declares a single coreApi range // and is served one chunk, so a client that claimed 1.0.0 while the server // answered 1.1.0 would be two answers to one question. -export const MODULE_API_VERSION = '1.5.0' +export const MODULE_API_VERSION = '1.6.0' diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx index c7b9136..7c3313d 100644 --- a/client/src/routes/admin/AdminLayout.jsx +++ b/client/src/routes/admin/AdminLayout.jsx @@ -76,6 +76,12 @@ export const NAV = [ items: [ { to: '/admin/moderation', label: 'Moderation', icon: IconShield, roles: ['admin', 'moderator'] }, { to: '/admin/moderation/appeals', label: 'Appeals', icon: IconShield, roles: ['admin', 'moderator'] }, + // Moderation rather than System: the screen's daily job is the + // reserved-name review queue, which is moderator work. The three actions + // that publish a game-written name are gated to admins server-side, so a + // moderator reaching this screen is correct — what they do here is file a + // request (TEAMS.md §2.9). + { to: '/admin/teams', label: 'Teams', icon: IconUsers, roles: ['admin', 'moderator'] }, ], }, { diff --git a/client/src/routes/admin/views/TeamsAdmin.jsx b/client/src/routes/admin/views/TeamsAdmin.jsx new file mode 100644 index 0000000..f55dbaf --- /dev/null +++ b/client/src/routes/admin/views/TeamsAdmin.jsx @@ -0,0 +1,301 @@ +import { useCallback, useEffect, useState } from 'react' +import { Loading, ErrorState } from '../../../components/PageState.jsx' +import { dateTime } from '../../../lib/format.js' +import { + freshnessOf, statusOf, gateLabelFor, describeRequest, leadershipOf, GATED_NOTE, +} from '../../../lib/teamAdmin.js' +import { useAuth } from '../../../contexts/AuthContext.jsx' +import { api } from '../../../api/client.js' + +// Admin → Teams (docs/website/TEAMS.md §2.4, §2.8, §2.9). +// +// Three panels, in the order an operator needs them: +// +// 1. **Sync state**, verbatim, including the last error. The screen's first job +// is to make "the shard has no Teams" and "core has not been able to ask for +// two hours" impossible to confuse — they render almost identically +// otherwise, and one is fine while the other is an outage. +// 2. **The review queue** — Teams auto-hidden because their name matched the +// impersonation list, each showing which term matched. +// 3. **The approval queue** — what moderators have asked to publish. +// +// Everything that decides what a row SAYS lives in lib/teamAdmin.js, which is +// plain JS and has tests; this file renders it. + +const TONE_COLOR = { ok: '#7fd0a4', warn: 'var(--accent)', bad: '#d98b84', idle: 'var(--muted)' } + +function Pill({ tone, children }) { + return ( + + {children} + + ) +} + +// ── Sync state ───────────────────────────────────────────────────────────── + +function SyncPanel({ sync, syncState, onResync, busy }) { + const freshness = freshnessOf(sync) + return ( +
+
+

Sync

+ {freshness.label} + +
+

{freshness.detail}

+ + {syncState && ( +
+
Module
{syncState.moduleId}
+
Last attempt
{dateTime(syncState.lastAttemptAt) || 'never'}
+
Last success
{dateTime(syncState.lastSuccessAt) || 'never'}
+
Consecutive failures
{syncState.consecutiveFailures}
+ {syncState.lastError && ( + <> + {/* Verbatim. An operator debugging a stale projection needs what the + provider actually said, not a friendlier paraphrase of it. */} +
Last error
+
{syncState.lastError}
+ + )} + {syncState.pendingEmptySince && ( + <> +
Empty answer held
+
+ since {dateTime(syncState.pendingEmptySince)} — an authoritative but empty list is + applied only if the next answer agrees. +
+ + )} +
+ )} +
+ ) +} + +// ── The reserved-name review queue ───────────────────────────────────────── + +function ReviewQueue({ rows, role, onAct, busy }) { + if (!rows.length) return null + return ( +
+

Names to review

+

+ These Teams are hidden from every public surface because their name matched a reserved term. + They work normally for their own members. {GATED_NOTE} +

+ + + + + + {rows.map((row) => ( + + + + + + + + ))} + +
NameMatchedMembersCreated
{row.name}{row.hidden_term}{row.member_count}{dateTime(row.created_at)} + +
+
+ ) +} + +// ── The approval queue ───────────────────────────────────────────────────── + +function RequestQueue({ rows, role, onDecide, busy }) { + if (!rows.length) return null + const canDecide = role === 'admin' + return ( +
+

Awaiting approval

+

+ {canDecide + ? 'Approving publishes the name; rejecting keeps the record and changes nothing.' + : 'Only an admin can decide these. Your own requests stay here until one does.'} +

+
    + {rows.map((row) => ( +
  • + {describeRequest(row)} + {dateTime(row.requested_at)} + {row.reason && “{row.reason}”} + {canDecide && ( + <> + + + + )} +
  • + ))} +
+
+ ) +} + +// ── One Team ─────────────────────────────────────────────────────────────── + +function TeamRow({ team, role, onAct, busy }) { + const status = statusOf(team) + return ( + + + {team.displayName} + {team.displayNameOverride && ( +
+ shown instead of “{team.name}” +
+ )} + + {status.label} + {team.memberCount} + {team.linkedCount} + {team.onlineCount} + {dateTime(team.rosterSyncedAt) || 'never'} + + {team.status === 'active' && (team.hidden + ? ( + + ) + : ( + + ))} + + + ) +} + +// ── The screen ───────────────────────────────────────────────────────────── + +export default function TeamsAdmin() { + const { user } = useAuth() + const role = user ? user.role : null + + const [data, setData] = useState(null) + const [review, setReview] = useState([]) + const [requests, setRequests] = useState([]) + const [error, setError] = useState('') + const [notice, setNotice] = useState('') + const [busy, setBusy] = useState(false) + + const load = useCallback(async () => { + setError('') + try { + const [teams, reviewQueue, requestQueue] = await Promise.all([ + api.admin.listTeams(), + api.admin.teamReviewQueue(), + api.admin.teamRequests('pending'), + ]) + setData(teams) + setReview(reviewQueue.teams || []) + setRequests(requestQueue.requests || []) + } catch (err) { + setError(err.message || 'Could not load Teams.') + } + }, []) + + useEffect(() => { load() }, [load]) + + async function run(fn, pendingMessage) { + setBusy(true) + setNotice('') + setError('') + try { + const result = await fn() + // The server decides whether an action applied or was filed, from the + // caller's live role. Saying so plainly is what stops a moderator thinking + // nothing happened. + if (result && result.pending) setNotice(pendingMessage) + await load() + } catch (err) { + setError(err.message || 'That did not work.') + } finally { + setBusy(false) + } + } + + const act = (id, action) => run( + () => (action === 'hide' ? api.admin.hideTeam(id) : api.admin.unhideTeam(id)), + 'Filed for approval. Nothing has changed publicly until an admin approves it.', + ) + + const decide = (id, status) => run( + () => api.admin.decideTeamRequest(id, status), + '', + ) + + const resync = () => run(async () => { + const result = await api.admin.resyncTeams() + // A refusal is the normal, designed outcome when the provider cannot answer, + // so it is reported as a result rather than thrown as an error. + if (!result.ok) setError(`Resync refused: ${result.reason}. Nothing was changed.`) + else if (result.quarantined) { + setNotice('The provider answered with an empty list. It is being held for confirmation, not applied.') + } + return null + }, '') + + if (error && !data) return + if (!data) return + + return ( +
+

Teams

+ {error && } + {notice &&

{notice}

} + + + + + +
+

All Teams

+ {!data.teams.length && ( +

+ {data.configured + ? 'No Teams in the projection yet.' + : 'No installed module supplies Teams, so there is nothing to show.'} +

+ )} + {data.teams.length > 0 && ( + + + + + + + + {data.teams.map((team) => ( + + ))} + +
NameStatusMembersLinkedOnlineRoster confirmed +
+ )} +
+
+ ) +} + +export { leadershipOf } diff --git a/client/test/teamAdmin.test.js b/client/test/teamAdmin.test.js new file mode 100644 index 0000000..b0e889a --- /dev/null +++ b/client/test/teamAdmin.test.js @@ -0,0 +1,140 @@ +// What Admin → Teams says (client/src/lib/teamAdmin.js). +// +// The test that earns this file: "no Teams" and "core has not been able to ask" +// must never read the same. They produce almost identical screens — an empty +// table — and one is fine while the other is an outage an operator needs to act +// on. Everything else here is in service of that distinction. +import { test } from 'node:test' +import assert from 'node:assert/strict' + +import { + freshnessOf, ago, statusOf, gateLabelFor, describeRequest, parsePayload, leadershipOf, TONE, +} from '../src/lib/teamAdmin.js' + +const minutesAgo = (n) => new Date(Date.now() - n * 60_000).toISOString() + +// ── Freshness: four states that must not be confused ─────────────────────── + +test('no provider is idle, not a fault', () => { + const f = freshnessOf({ configured: false }) + assert.equal(f.tone, TONE.idle) + assert.match(f.label, /No Team provider/) +}) + +test('never synced is reported as never synced, not as an empty shard', () => { + // The failure this prevents: an empty projection core has never confirmed, + // rendered as though the game genuinely has no Teams. + const f = freshnessOf({ configured: true, lastSyncAt: null }) + assert.equal(f.tone, TONE.bad) + assert.equal(f.label, 'Never synced') + assert.match(f.detail, /not a confirmed empty shard/) +}) + +test('stale says how old it is', () => { + const f = freshnessOf({ configured: true, stale: true, lastSyncAt: minutesAgo(14) }) + assert.equal(f.tone, TONE.warn) + assert.equal(f.label, 'Stale') + assert.match(f.detail, /14 minutes ago/) +}) + +test('current says so plainly', () => { + const f = freshnessOf({ configured: true, stale: false, lastSyncAt: minutesAgo(2) }) + assert.equal(f.tone, TONE.ok) + assert.equal(f.label, 'Current') +}) + +test('ago is deliberately coarse', () => { + // Second-level precision would be false comfort about a projection whose poll + // interval is fifteen minutes. + assert.equal(ago(null), 'never') + assert.equal(ago(new Date().toISOString()), 'just now') + assert.equal(ago(minutesAgo(14)), '14 minutes ago') + assert.equal(ago(minutesAgo(60)), '1 hour ago') + assert.equal(ago(minutesAgo(180)), '3 hours ago') + assert.equal(ago(minutesAgo(60 * 72)), '3 days ago') +}) + +// ── Status ───────────────────────────────────────────────────────────────── + +test('the four Team statuses are distinguishable', () => { + assert.equal(statusOf({ status: 'active' }).label, 'Public') + assert.equal(statusOf({ status: 'active', hidden: 1, hiddenReason: 'reserved_name' }).label, 'Hidden — reserved name') + assert.equal(statusOf({ status: 'active', hidden: 1, hiddenReason: 'staff' }).label, 'Hidden by staff') + assert.equal(statusOf({ status: 'archived', archivedReason: 'disbanded' }).label, 'Archived') + assert.equal(statusOf({ status: 'archived', archivedReason: 'renamed' }).label, 'Renamed') +}) + +test('a reserved-name hide is the loudest tone', () => { + assert.equal(statusOf({ status: 'active', hidden: 1, hiddenReason: 'reserved_name' }).tone, TONE.bad) + assert.equal(statusOf({ status: 'active', hidden: 1, hiddenReason: 'staff' }).tone, TONE.warn) +}) + +// ── The gate, described honestly ─────────────────────────────────────────── + +test('the button says what will actually happen for this role', () => { + // The server decides from the live role; this only describes it. Saying + // "Publish" to a moderator would make the pending result a surprise. + assert.equal(gateLabelFor('admin', 'Publish'), 'Publish') + assert.equal(gateLabelFor('moderator', 'Publish'), 'Request publish') +}) + +// ── The approval queue ───────────────────────────────────────────────────── + +test('a request describes itself, including the name being published', () => { + assert.equal( + describeRequest({ action: 'unhide', requested_username: 'mod1', team_name: 'Admin' }), + 'mod1 asks to publish “Admin”', + ) + assert.equal( + describeRequest({ + action: 'display_name_override', requested_username: 'mod1', team_name: 'Admin', + payload: { displayName: 'The Old Guard' }, + }), + 'mod1 asks to display “Admin” as “The Old Guard”', + ) + assert.equal( + describeRequest({ action: 'clear_display_name_override', requested_username: 'mod1', team_name: 'X' }), + 'mod1 asks to clear the display name on “X”', + ) +}) + +test('a deleted requester still reads as a sentence', () => { + // §2.10 sets requested_by to NULL and keeps the username snapshot; when even + // that is gone the queue must not render "null asks to publish". + assert.match(describeRequest({ action: 'unhide', team_name: 'Admin' }), /^a deleted user asks/) +}) + +test('a payload arrives parsed or as a string, and both work', () => { + assert.deepEqual(parsePayload({ displayName: 'X' }), { displayName: 'X' }) + assert.deepEqual(parsePayload('{"displayName":"X"}'), { displayName: 'X' }) + assert.deepEqual(parsePayload(null), {}) + assert.deepEqual(parsePayload('not json'), {}) +}) + +// ── Leadership shows the decision, not just the answer ───────────────────── + +test('an unoverridden member reads straight from the projection', () => { + const l = leadershipOf({ isLeader: true, isLeaderSynced: true }) + assert.equal(l.isLeader, true) + assert.equal(l.overridden, false) + assert.equal(l.note, null) +}) + +test('an override is shown AS an override, with what the game says', () => { + // Staff looking at a roster need to see that a decision was made, not a fact + // that looks like the game's. + const l = leadershipOf({ + isLeaderSynced: true, + leaderOverride: { effect: 'deny', by: 'mod1', reason: 'harassment' }, + }) + assert.equal(l.isLeader, false) + assert.equal(l.overridden, true) + assert.match(l.note, /Denied by mod1 — harassment/) + assert.match(l.note, /the game says leader/) +}) + +test('a grant override says the game disagrees', () => { + const l = leadershipOf({ isLeaderSynced: false, leaderOverride: { effect: 'grant', by: 'root' } }) + assert.equal(l.isLeader, true) + assert.match(l.note, /the game says not a leader/) +}) diff --git a/server/db/schema.sql b/server/db/schema.sql index 3a6c6e5..2808561 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -845,6 +845,212 @@ CREATE TABLE IF NOT EXISTS installed_modules ( INDEX idx_installed_modules_state (state) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +-- ── Teams (docs/website/TEAMS.md Part 2, phase 2) ───────────────────────────── +-- +-- A Team is a core platform entity POPULATED by a module and owned by core. The +-- module answers "what teams exist and who is in them" through the team provider +-- (MODULE_API.md — registerTeamProvider); core stores the answer, gates it and +-- displays it. Every table below is core-internal (TEAMS.md §10.3): a module must +-- never read or write one, even though a module is what fills them. +-- +-- Note the tables carry no `_` prefix, correctly — MODULE_API.md §2.6's +-- prefix rule binds modules, and these are core's. + +-- The Team itself. `external_id` is the module's own stable identity for it +-- (module-uo sends the persistent ServUO Guild.Id) and is opaque to core. +-- +-- `name` is IMMUTABLE for the life of the row (§2.2): a rename archives this row +-- with archived_reason='renamed' and creates a new one, so the old Team keeps its +-- activity, its grants and its forum as a read-only record. What staff can change +-- is display_name_override, which changes what is RENDERED and never what the row +-- IS — identity and display are different things and only identity is frozen. +CREATE TABLE IF NOT EXISTS teams ( + id INT AUTO_INCREMENT PRIMARY KEY, + module_id VARCHAR(32) NOT NULL, -- which module is authoritative + external_id VARCHAR(191) NOT NULL, -- opaque to core + name VARCHAR(160) NOT NULL, + abbr VARCHAR(32) NULL, + slug VARCHAR(191) NOT NULL, -- derived from name, unique among ACTIVE teams + status ENUM('active','archived') NOT NULL DEFAULT 'active', + meta JSON NULL, -- module-supplied, opaque (alliance, crest, …) + member_count INT NOT NULL DEFAULT 0, -- denormalised from team_members + linked_count INT NOT NULL DEFAULT 0, -- members whose user_id is not null + online_count INT NOT NULL DEFAULT 0, -- last known; refreshed by sync + -- Public suppression, independent of status. A hidden Team still works + -- completely for its own members; it is absent from public surfaces (§2.8). + hidden TINYINT(1) NOT NULL DEFAULT 0, + hidden_reason ENUM('reserved_name','staff') NULL, + hidden_term VARCHAR(64) NULL, -- which reserved term matched, for the review queue + -- Set once staff have made an explicit decision about the name. Re-screening + -- runs on every sync, and this is what stops it re-hiding a Team a human has + -- already allowed — without it the override would be undone every 15 minutes. + name_reviewed_at DATETIME NULL, + -- PER-TEAM freshness, which team_sync_state cannot express: it holds one row per + -- MODULE, and §2.4 gate 3 leaves one Team's roster untouched while the others + -- sync normally. Without a per-Team stamp that Team's page would claim the + -- module's last success as its own, which is precisely the staleness the rule + -- exists to surface. Bumped only when a roster is actually applied. + roster_synced_at DATETIME NULL, + -- §2.4 gate 4's per-Team quarantine, the twin of team_sync_state.pending_empty_ + -- since: an authoritative-but-empty ROSTER for a Team that currently has members + -- is remembered here and applied only if the next answer agrees. + members_empty_since DATETIME NULL, + -- Staff may change what is DISPLAYED without touching identity (§2.8.3). + display_name_override VARCHAR(160) NULL, + -- The successor row written at archive time when this Team was renamed, so the + -- old slug can still resolve and explain itself rather than 404 (§2.2). + succeeded_by INT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + archived_at DATETIME NULL, + archived_reason VARCHAR(64) NULL, -- 'disbanded' | 'renamed' | 'staff' + -- A generated column is how "unique among ACTIVE rows only" is expressed without + -- a partial index (MariaDB has none): NULL never collides in a UNIQUE key, so + -- any number of archived rows may share an external_id. + active_key VARCHAR(191) AS (IF(status='active', external_id, NULL)) STORED, + active_slug VARCHAR(191) AS (IF(status='active', slug, NULL)) STORED, + UNIQUE KEY uq_teams_active (module_id, active_key), + UNIQUE KEY uq_teams_active_slug (active_slug), + INDEX idx_teams_status (status), + INDEX idx_teams_slug (slug), + INDEX idx_teams_review (hidden, hidden_reason), + -- Self-referential and deliberately SET NULL: a successor may itself be archived + -- and eventually pruned, and losing the pointer must not take the old row with it. + CONSTRAINT fk_teams_succeeded_by FOREIGN KEY (succeeded_by) REFERENCES teams(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- The membership PROJECTION. Module-authoritative; core only mirrors it, and the +-- sync is the ONLY writer (§2.5 path 1). Rows are soft-departed rather than +-- deleted so history and rejoin detection survive, and so the activity feed can +-- still name a departed member. +-- +-- user_id is resolved BY THE MODULE (it owns the game↔site link table); core never +-- resolves it, because doing so would be core reading a module's table by name. +CREATE TABLE IF NOT EXISTS team_members ( + team_id INT NOT NULL, + member_key VARCHAR(191) NOT NULL, -- module's stable member id (UO: character serial) + display_name VARCHAR(160) NULL, -- in-game name + user_id INT NULL, -- resolved by the MODULE; NULL = unlinked + is_leader TINYINT(1) NOT NULL DEFAULT 0, + rank_label VARCHAR(48) NULL, -- module vocabulary, opaque to core + online TINYINT(1) NOT NULL DEFAULT 0, + status ENUM('active','departed') NOT NULL DEFAULT 'active', + first_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + departed_at DATETIME NULL, + PRIMARY KEY (team_id, member_key), + -- SET NULL, not CASCADE (§2.10): deleting a site account does not remove the + -- character from the guild — only the link to the site goes. + CONSTRAINT fk_team_members_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE, + CONSTRAINT fk_team_members_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL, + INDEX idx_team_members_user (user_id), + INDEX idx_team_members_status (team_id, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Freshness of the module's answer. One row per module. THE table invariant 1 +-- ("module unavailability is staleness, never emptiness") is enforced against. +CREATE TABLE IF NOT EXISTS team_sync_state ( + module_id VARCHAR(32) NOT NULL PRIMARY KEY, + last_attempt_at DATETIME NULL, + last_success_at DATETIME NULL, + consecutive_failures INT NOT NULL DEFAULT 0, + last_error VARCHAR(500) NULL, + -- The quarantine for §2.4's mass-deletion guard: an authoritative-but-empty + -- answer is remembered here and applied only if the NEXT one agrees. + pending_empty_since DATETIME NULL, + INDEX idx_team_sync_success (last_success_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Staff leadership overrides (§2.5.1), applied ON TOP of the synced value at read +-- time. The projection is never mutated: the sync keeps writing what the game +-- says and this keeps saying what staff decided, which is the whole point — an +-- override the sync clobbered every 15 minutes would be useless. +CREATE TABLE IF NOT EXISTS team_leader_overrides ( + team_id INT NOT NULL, + member_key VARCHAR(191) NOT NULL, + effect ENUM('grant','deny') NOT NULL, + actor_user_id INT NULL, + actor_username VARCHAR(32) NULL, -- snapshot, so the record survives the account + reason VARCHAR(255) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (team_id, member_key), + CONSTRAINT fk_tlo_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE, + CONSTRAINT fk_tlo_actor FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Forum access grants (§2.5 path 3) — an append-only grant/revoke ledger that is +-- ALSO the current state. An active grant is one with revoked_at IS NULL, and a +-- generated column is how "one active grant per (team,user)" is expressed without +-- a partial index (MariaDB has none): NULL never collides in a UNIQUE key. +-- +-- The table lands here, in the phase that builds the resolver, so forumAccess() is +-- written once and its non-contamination tests are real. The grant/revoke FLOW, +-- the per-Team cap and the leader UI are phase 4's; nothing writes this table yet. +-- +-- user_id is NULLABLE and SET NULL, which contradicts the sketch in TEAMS.md §2.5 +-- and follows §2.10, which settled it deliberately: CASCADE would delete the audit +-- trail of who granted whom, which is exactly what an audit exists to survive. The +-- username snapshots keep the record readable after the account is gone. +-- +-- THE TWO CANNOT BOTH BE HAD AS §2.5 WROTE THEM, and this is why the marker below +-- is a bare flag rather than §2.5's `active_user AS (IF(revoked_at IS NULL, +-- user_id, NULL))`. MariaDB refuses `ON DELETE SET NULL` on a foreign key whose +-- column is a base column of a STORED generated column (ER_GENERATED_COLUMN_ +-- FUNCTION_IS_NOT_ALLOWED, 1901) — so §2.5's generated column forces §2.10's +-- CASCADE, and the audit trail with it. Deriving the marker from `revoked_at` +-- ALONE and putting user_id in the KEY instead gives identical semantics: at most +-- one active row per (team_id, user_id), unlimited revoked rows, and user_id free +-- to be a SET NULL foreign key. Verified against MariaDB 11 both ways. +CREATE TABLE IF NOT EXISTS team_forum_grants ( + id INT AUTO_INCREMENT PRIMARY KEY, + team_id INT NOT NULL, + user_id INT NULL, + username VARCHAR(32) NULL, -- snapshot of the grantee at grant time + granted_by INT NULL, -- NULL for a system grant, or a deleted actor + granted_username VARCHAR(32) NULL, -- snapshot of the actor + granted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + reason VARCHAR(255) NULL, + revoked_by INT NULL, + revoked_username VARCHAR(32) NULL, + revoked_at DATETIME NULL, + revoke_reason VARCHAR(255) NULL, + active_marker TINYINT(1) AS (IF(revoked_at IS NULL, 1, NULL)) STORED, + UNIQUE KEY uq_team_forum_grant_active (team_id, user_id, active_marker), + CONSTRAINT fk_tfg_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE, + CONSTRAINT fk_tfg_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_tfg_granted_by FOREIGN KEY (granted_by) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_tfg_revoked_by FOREIGN KEY (revoked_by) REFERENCES users(id) ON DELETE SET NULL, + INDEX idx_tfg_user (user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- The §2.9 approval queue. A MODERATOR performing one of the three actions that +-- publish untrusted game-sourced strings creates a pending row here; an ADMIN +-- performing one applies it immediately. Rows are kept after a decision — "a +-- moderator asked to publish this name and an admin refused" is the record worth +-- having. +-- +-- `action` + `payload` means a fourth gated action is an enum value rather than a +-- schema change. That is room to extend, not an invitation: nothing else is gated +-- today, and nothing should be without asking §2.9's question first. +CREATE TABLE IF NOT EXISTS team_moderation_requests ( + id INT AUTO_INCREMENT PRIMARY KEY, + team_id INT NOT NULL, + action ENUM('unhide','display_name_override','clear_display_name_override') NOT NULL, + payload JSON NULL, -- e.g. { "displayName": "…" } + reason VARCHAR(255) NULL, + requested_by INT NULL, + requested_username VARCHAR(32) NULL, -- snapshot (§2.10) + requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + status ENUM('pending','approved','rejected','withdrawn') NOT NULL DEFAULT 'pending', + decided_by INT NULL, + decided_username VARCHAR(32) NULL, + decided_at DATETIME NULL, + decision_note VARCHAR(255) NULL, + CONSTRAINT fk_tmr_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE, + CONSTRAINT fk_tmr_requested_by FOREIGN KEY (requested_by) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_tmr_decided_by FOREIGN KEY (decided_by) REFERENCES users(id) ON DELETE SET NULL, + INDEX idx_tmr_queue (status, requested_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + -- Migrations for databases created before the wiki upgrade. Each statement uses -- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get -- these columns from the CREATE TABLE above; existing installs get them here. diff --git a/server/routes.guards.json b/server/routes.guards.json index 05cefc5..fb4d35e 100644 --- a/server/routes.guards.json +++ b/server/routes.guards.json @@ -730,6 +730,145 @@ "validate" ] }, + { + "method": "GET", + "path": "/api/v1/admin/teams", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/teams/:id", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/teams/:id/archive", + "handlers": 4, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/teams/:id/display-name", + "handlers": 5, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/teams/:id/grants", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/teams/:id/hide", + "handlers": 4, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/teams/:id/leader-override", + "handlers": 6, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "DELETE", + "path": "/api/v1/admin/teams/:id/leader-override/:memberKey", + "handlers": 4, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/teams/:id/unhide", + "handlers": 4, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/teams/requests", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/teams/requests/:id/decide", + "handlers": 5, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/teams/resync", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/teams/review", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, { "method": "POST", "path": "/api/v1/admin/uploads", @@ -1499,6 +1638,24 @@ "requireAuth" ] }, + { + "method": "GET", + "path": "/api/v1/player/teams", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "GET", + "path": "/api/v1/player/teams/:slug/access", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, { "method": "POST", "path": "/api/v1/public/contact", @@ -1556,6 +1713,30 @@ "handlers": 1, "gates": [] }, + { + "method": "GET", + "path": "/api/v1/public/teams", + "handlers": 2, + "gates": [ + "siteMode" + ] + }, + { + "method": "GET", + "path": "/api/v1/public/teams/:slug", + "handlers": 2, + "gates": [ + "siteMode" + ] + }, + { + "method": "GET", + "path": "/api/v1/public/teams/:slug/members", + "handlers": 2, + "gates": [ + "siteMode" + ] + }, { "method": "GET", "path": "/api/v1/public/version", diff --git a/server/routes.manifest.json b/server/routes.manifest.json index 3860ae1..be09566 100644 --- a/server/routes.manifest.json +++ b/server/routes.manifest.json @@ -293,6 +293,58 @@ "method": "PUT", "path": "/api/v1/admin/site-mode" }, + { + "method": "GET", + "path": "/api/v1/admin/teams" + }, + { + "method": "GET", + "path": "/api/v1/admin/teams/:id" + }, + { + "method": "POST", + "path": "/api/v1/admin/teams/:id/archive" + }, + { + "method": "POST", + "path": "/api/v1/admin/teams/:id/display-name" + }, + { + "method": "GET", + "path": "/api/v1/admin/teams/:id/grants" + }, + { + "method": "POST", + "path": "/api/v1/admin/teams/:id/hide" + }, + { + "method": "POST", + "path": "/api/v1/admin/teams/:id/leader-override" + }, + { + "method": "DELETE", + "path": "/api/v1/admin/teams/:id/leader-override/:memberKey" + }, + { + "method": "POST", + "path": "/api/v1/admin/teams/:id/unhide" + }, + { + "method": "GET", + "path": "/api/v1/admin/teams/requests" + }, + { + "method": "POST", + "path": "/api/v1/admin/teams/requests/:id/decide" + }, + { + "method": "POST", + "path": "/api/v1/admin/teams/resync" + }, + { + "method": "GET", + "path": "/api/v1/admin/teams/review" + }, { "method": "POST", "path": "/api/v1/admin/uploads" @@ -605,6 +657,14 @@ "method": "GET", "path": "/api/v1/player/appeals/eligible" }, + { + "method": "GET", + "path": "/api/v1/player/teams" + }, + { + "method": "GET", + "path": "/api/v1/player/teams/:slug/access" + }, { "method": "POST", "path": "/api/v1/public/contact" @@ -637,6 +697,18 @@ "method": "GET", "path": "/api/v1/public/status" }, + { + "method": "GET", + "path": "/api/v1/public/teams" + }, + { + "method": "GET", + "path": "/api/v1/public/teams/:slug" + }, + { + "method": "GET", + "path": "/api/v1/public/teams/:slug/members" + }, { "method": "GET", "path": "/api/v1/public/version" diff --git a/server/src/model/teams/teamAccess.db.js b/server/src/model/teams/teamAccess.db.js new file mode 100644 index 0000000..e21fe9a --- /dev/null +++ b/server/src/model/teams/teamAccess.db.js @@ -0,0 +1,94 @@ +// SQL for the two tables the access resolver reads: forum grants (path 3) and +// staff leadership overrides (§2.5.1). +// +// Kept separate from teams.db.js on purpose. The four authority paths are four +// tables answering four questions, and the single most important structural rule +// in TEAMS.md is that no resolver reads another path's table — a file boundary is +// a cheap way to make crossing one visible in a diff. + +const { query } = require('../../utils/db') + +// ── team_forum_grants (path 3) ───────────────────────────────────────────── + +const GRANT_COLUMNS = ` + id, team_id, user_id, username, granted_by, granted_username, granted_at, reason, + revoked_by, revoked_username, revoked_at, revoke_reason` + +/** The caller's ACTIVE grant on a team, or undefined. At most one, by the unique key. */ +async function activeGrant(teamId, userId) { + const rows = await query( + `SELECT ${GRANT_COLUMNS} FROM team_forum_grants + WHERE team_id = ? AND user_id = ? AND revoked_at IS NULL`, + [teamId, userId], + ) + return rows[0] +} + +/** The whole ledger for a team, revoked rows included — the admin grant view. */ +async function grantLedger(teamId) { + return query( + `SELECT ${GRANT_COLUMNS} FROM team_forum_grants WHERE team_id = ? ORDER BY granted_at DESC, id DESC`, + [teamId], + ) +} + +/** Active grants only, for the "Forum guests" list and the per-team cap. */ +async function activeGrants(teamId) { + return query( + `SELECT ${GRANT_COLUMNS} FROM team_forum_grants WHERE team_id = ? AND revoked_at IS NULL + ORDER BY granted_at`, + [teamId], + ) +} + +// ── team_leader_overrides (§2.5.1) ───────────────────────────────────────── + +const OVERRIDE_COLUMNS = 'team_id, member_key, effect, actor_user_id, actor_username, reason, created_at' + +async function overridesForTeam(teamId) { + return query(`SELECT ${OVERRIDE_COLUMNS} FROM team_leader_overrides WHERE team_id = ? ORDER BY member_key`, + [teamId]) +} + +async function overrideFor(teamId, memberKey) { + const rows = await query( + `SELECT ${OVERRIDE_COLUMNS} FROM team_leader_overrides WHERE team_id = ? AND member_key = ?`, + [teamId, memberKey], + ) + return rows[0] +} + +/** + * Set or replace one override. + * + * The projection is never touched by this — `team_members.is_leader` keeps saying + * what the game says and this keeps saying what staff decided, which is the entire + * point (§2.5.1). An override applied INTO the projection would be clobbered by + * the next sync, fifteen minutes later. + */ +async function setOverride({ teamId, memberKey, effect, actorUserId, actorUsername, reason }) { + await query( + `INSERT INTO team_leader_overrides (team_id, member_key, effect, actor_user_id, actor_username, reason) + VALUES (?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + effect = VALUES(effect), actor_user_id = VALUES(actor_user_id), + actor_username = VALUES(actor_username), reason = VALUES(reason), created_at = NOW()`, + [teamId, memberKey, effect, actorUserId, actorUsername, reason], + ) +} + +async function clearOverride(teamId, memberKey) { + const res = await query('DELETE FROM team_leader_overrides WHERE team_id = ? AND member_key = ?', + [teamId, memberKey]) + return res.affectedRows > 0 +} + +module.exports = { + activeGrant, + grantLedger, + activeGrants, + overridesForTeam, + overrideFor, + setOverride, + clearOverride, +} diff --git a/server/src/model/teams/teamAccess.model.js b/server/src/model/teams/teamAccess.model.js new file mode 100644 index 0000000..83cecfe --- /dev/null +++ b/server/src/model/teams/teamAccess.model.js @@ -0,0 +1,131 @@ +// ── The four authority paths ─────────────────────────────────────────────── +// +// The single most important structural rule in TEAMS.md (§2.5): these are four +// tables answering four questions, and **no resolver reads another path's table.** +// +// 1. Is this account a member? module team_members +// 2. Does this account lead the Team? module team_members.is_leader, +// plus a staff override +// 3. May it use the Team forum? CORE team_forum_grants OR path 1 +// 4. May it get external-platform CORE, nothing of its own +// access? derived +// +// The temptation this file exists to resist is collapsing 1 and 3 into one +// boolean. They answer different questions about different populations: a forum +// grant may name any Runic Gateway account, including one with no game identity +// at all — that is the point of it, since letting an unlinked guildmate into the +// forum must not require a staff ticket. Treating "has forum access" as "is a +// member" would put that person on the roster, in the member count, and into the +// external-platform grant, which is where it stops being a modelling preference +// and becomes an impersonation risk (path 4 below). +// +// Non-contamination is the invariant: a manual grant never writes the membership +// projection, in either direction, ever. Both facts coexist and neither migrates +// into the other. + +const accessDb = require('./teamAccess.db') +const teamsDb = require('./teams.db') +const identities = require('../userIdentities/userIdentities.model') + +/** + * Path 3 — forum access. Two reads, OR'd, and nothing else. + * + * `viaGrant` is reported even when membership also holds, deliberately: both + * facts are true, the UI presents membership as the current reason, and the grant + * survives as audit history. Collapsing them into one boolean is what loses the + * record of who let this person in and why. + */ +async function forumAccess(teamId, userId) { + if (!userId) return { allowed: false, viaMembership: false, viaGrant: false, isLeader: false } + + const [grant, member] = await Promise.all([ + accessDb.activeGrant(teamId, userId), // path 3's own table + teamsDb.activeByUser(teamId, userId), // path 1 + ]) + + return { + allowed: Boolean(grant) || Boolean(member), + viaMembership: Boolean(member), + viaGrant: Boolean(grant), + isLeader: member ? await isLeader(teamId, member) : false, + } +} + +/** + * Path 2 — leadership, with the staff override applied ON TOP of the synced value + * at read time (§2.5.1). + * + * Applied at read rather than written into the projection because the sync owns + * that column and rewrites it every interval. An override that lived in + * `team_members` would be undone fifteen minutes after staff set it, which is the + * whole reason this is a separate table read here. + */ +async function isLeader(teamId, member) { + if (!member) return false + const override = await accessDb.overrideFor(teamId, member.member_key) + if (override) return override.effect === 'grant' + return Boolean(member.is_leader) +} + +/** Leadership for a caller identified by user id rather than by a member row. */ +async function isLeaderByUser(teamId, userId) { + if (!userId) return false + const member = await teamsDb.activeByUser(teamId, userId) + return isLeader(teamId, member) +} + +/** + * Path 4 — external-platform eligibility. Computed, no table of its own, and + * deliberately blind to path 3. + * + * The reason, stated so nobody "fixes" it later: an integration cannot verify + * that an unlinked, forum-granted account corresponds to a real game member, so + * it must not hand that account a privilege on a platform where impersonation has + * consequences. A forum is a room on the operator's own site with a known + * moderator; a Discord role is an identity claim in someone else's space. + */ +async function externalEligible(teamId, userId, platform) { + if (!userId || !platform) return false + const member = await teamsDb.activeByUser(teamId, userId) // path 1 ONLY + if (!member || member.user_id == null) return false // must be a LINKED game member + const linked = await identities.listForUser(userId) + return linked.some((i) => i.provider === platform) +} + +/** + * A team's roster with overrides folded in, for the admin view and the Team page. + * + * The rows returned carry `is_leader` as RESOLVED — synced value plus override — + * and `is_leader_synced` as what the game actually said, so the admin surface can + * show that a decision was made rather than silently presenting it as fact. + */ +async function rosterWithOverrides(teamId, { includeDeparted = false } = {}) { + const [members, overrides] = await Promise.all([ + teamsDb.membersByTeam(teamId, { includeDeparted }), + accessDb.overridesForTeam(teamId), + ]) + const byKey = new Map(overrides.map((o) => [o.member_key, o])) + return members.map((m) => { + const override = byKey.get(m.member_key) + return { + ...m, + is_leader_synced: Boolean(m.is_leader), + is_leader: override ? override.effect === 'grant' : Boolean(m.is_leader), + leader_override: override + ? { effect: override.effect, reason: override.reason, by: override.actor_username, at: override.created_at } + : null, + } + }) +} + +module.exports = { + forumAccess, + isLeader, + isLeaderByUser, + externalEligible, + rosterWithOverrides, + setLeaderOverride: accessDb.setOverride, + clearLeaderOverride: accessDb.clearOverride, + grantLedger: accessDb.grantLedger, + activeGrants: accessDb.activeGrants, +} diff --git a/server/src/model/teams/teamModeration.db.js b/server/src/model/teams/teamModeration.db.js new file mode 100644 index 0000000..f69174f --- /dev/null +++ b/server/src/model/teams/teamModeration.db.js @@ -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, +} diff --git a/server/src/model/teams/teamModeration.model.js b/server/src/model/teams/teamModeration.model.js new file mode 100644 index 0000000..991073b --- /dev/null +++ b/server/src/model/teams/teamModeration.model.js @@ -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, +} diff --git a/server/src/model/teams/teamProvider.js b/server/src/model/teams/teamProvider.js new file mode 100644 index 0000000..945f983 --- /dev/null +++ b/server/src/model/teams/teamProvider.js @@ -0,0 +1,182 @@ +// ── Calling the Team provider ────────────────────────────────────────────── +// +// The one place core asks a module a question and waits for the answer +// (docs/website/TEAMS.md §2.3). Everything here exists to serve invariant 1: +// +// **Module unavailability is staleness, never emptiness.** +// +// No Team subsystem may apply a destructive result derived from a failed, +// timed-out or unanswered module call. This file is where "failed" is defined, and +// it is deliberately generous about what counts: a rejected promise, a timeout, a +// non-object, a missing `ok`, or a structurally malformed row all leave with the +// same `{ ok: false }` the module would have sent deliberately. +// +// **There is no shape a failure can take that core reads as "zero teams".** That +// is the whole argument for the envelope, and the reason the provider signature is +// not the obvious `getTeams(): Team[]` — a bare array has exactly one such shape, +// `[]`, and it is the one a module returns while its sidecar is still connecting. +// +// Nothing here touches the database. It calls the module and hands back a value +// the reconciler can trust the SHAPE of; whether to ACT on it is §2.4's question. + +const registries = require('../../modules/registries') +const log = require('../../utils/logger')('teams') + +// The budget from §2.3. A provider is answering from its own cache or its own +// sidecar client, both of which have their own timeouts well inside this; a call +// that reaches ten seconds is wedged, not slow. +const CALL_TIMEOUT_MS = 10_000 + +/** A uniform refusal. `reason` is for the operator, via team_sync_state. */ +const fail = (reason) => ({ ok: false, reason }) + +/** + * Await `promise` with a timeout that cannot outlive the call. + * + * The timer is always cleared — including on the winning path — because an + * uncleared 10s timer holds the event loop open, which in a test run means the + * process hangs long after the assertions passed. The suite already learned this + * one from a mariadb pool (test/_setup.js). + * + * It is also `unref`ed, which covers the case clearing cannot: when the module's + * promise NEVER settles, the race stays pending and there is nothing to clear + * until the deadline fires. An unreffed timer still fires normally while the + * process is alive — the server's own listener is what keeps it alive — but it no + * longer holds a shutdown open for ten seconds waiting on a module that is not + * going to answer. + */ +function withTimeout(promise, ms) { + let timer + const timeout = new Promise((resolve) => { + timer = setTimeout(() => resolve(fail(`provider did not answer within ${ms}ms`)), ms) + if (typeof timer.unref === 'function') timer.unref() + }) + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)) +} + +/** + * Call one provider method and normalise whatever comes back into an envelope. + * + * `normalise` is only ever run on an `ok` answer, and may itself return a refusal + * — a structurally malformed row is treated as a failed call rather than as data + * to salvage. Salvaging is the dangerous option: dropping one unreadable member + * from a roster is indistinguishable, downstream, from that member having left, + * and the sync would mark them departed. Refusing costs one stale interval. + */ +async function call(method, normalise, ...args) { + const provider = registries.registeredTeamProvider() + if (!provider) return fail('no team provider is registered') + + let answer + try { + answer = await withTimeout(Promise.resolve().then(() => provider[method](...args)), CALL_TIMEOUT_MS) + } catch (err) { + // A rejected promise is a module that threw, which is exactly as + // unauthoritative as one that answered `{ ok: false }`. + return fail(`${method}() threw: ${err.message}`) + } + + if (!answer || typeof answer !== 'object' || Array.isArray(answer)) { + return fail(`${method}() returned ${Array.isArray(answer) ? 'an array' : typeof answer}, not an envelope`) + } + // `ok` must be present and true. A module that forgot the field is not one + // asserting authority, and reading a missing field as truthy would put the + // single most consequential decision in this file on a typo. + if (answer.ok !== true) return fail(answer.reason || `${method}() answered not-ok`) + + const normalised = normalise(answer) + if (normalised.ok === false) { + log.warn('team provider answered with a malformed payload', { + owner: provider.owner, method, reason: normalised.reason, + }) + } + return normalised +} + +// `complete` defaults to TRUE when the module omits it, matching §2.3: the +// envelope's optional field marks a partial answer, so its absence is the +// ordinary authoritative case. A module that cannot enumerate exhaustively says +// so explicitly. +const isComplete = (answer) => answer.complete !== false + +const str = (v) => (typeof v === 'string' ? v.trim() : '') + +/** `{ ok, complete, teams: [{ externalId, name, abbr, meta }] }` */ +function normaliseTeams(answer) { + if (!Array.isArray(answer.teams)) return fail('getTeams() answered ok with no teams array') + const teams = [] + for (const raw of answer.teams) { + const externalId = str(raw && raw.externalId) + const name = str(raw && raw.name) + // Both are load-bearing and neither has a safe default: externalId is the + // identity the whole rename rule (§2.2) turns on, and a Team with no name has + // no slug and no page. + if (!externalId) return fail('a team in getTeams() has no externalId') + if (!name) return fail(`team "${externalId}" has no name`) + teams.push({ + externalId, + name, + abbr: str(raw.abbr) || null, + // Opaque by contract (§10.5) — stored and handed back, never branched on. + meta: raw.meta && typeof raw.meta === 'object' ? raw.meta : null, + }) + } + return { ok: true, complete: isComplete(answer), teams } +} + +/** `{ ok, complete, members: [{ memberKey, displayName, rankLabel, leader, online, userId }] }` */ +function normaliseMembers(answer) { + if (!Array.isArray(answer.members)) return fail('getTeamMembers() answered ok with no members array') + const members = [] + const seen = new Set() + for (const raw of answer.members) { + const memberKey = str(raw && raw.memberKey) + if (!memberKey) return fail('a member has no memberKey') + // A duplicate key would upsert twice and inflate no count but confuse every + // reader; it also means the module's own identity rule is broken, which is + // worth surfacing rather than quietly collapsing. + if (seen.has(memberKey)) return fail(`member "${memberKey}" appears twice`) + seen.add(memberKey) + members.push({ + memberKey, + displayName: str(raw.displayName) || null, + rankLabel: str(raw.rankLabel) || null, + leader: Boolean(raw.leader), + online: Boolean(raw.online), + // Resolved BY THE MODULE — it owns the game↔site link table (§2.3). Core + // takes the number and never looks it up. + userId: Number.isInteger(raw.userId) && raw.userId > 0 ? raw.userId : null, + }) + } + return { ok: true, complete: isComplete(answer), members } +} + +/** `{ ok, leaders: [memberKey] }` */ +function normaliseLeaders(answer) { + if (!Array.isArray(answer.leaders)) return fail('getTeamLeaders() answered ok with no leaders array') + const leaders = [] + for (const raw of answer.leaders) { + const key = str(raw) + if (!key) return fail('a leader entry is not a member key') + if (!leaders.includes(key)) leaders.push(key) + } + return { ok: true, leaders } +} + +const getTeams = () => call('getTeams', normaliseTeams) +const getTeamMembers = (externalId) => call('getTeamMembers', normaliseMembers, externalId) +const getTeamLeaders = (externalId) => call('getTeamLeaders', normaliseLeaders, externalId) + +/** Which module is authoritative, or null. The reconciler keys sync state on it. */ +const providerModuleId = () => { + const provider = registries.registeredTeamProvider() + return provider ? provider.owner : null +} + +module.exports = { + getTeams, + getTeamMembers, + getTeamLeaders, + providerModuleId, + CALL_TIMEOUT_MS, +} diff --git a/server/src/model/teams/teamSlug.js b/server/src/model/teams/teamSlug.js new file mode 100644 index 0000000..da1eb47 --- /dev/null +++ b/server/src/model/teams/teamSlug.js @@ -0,0 +1,50 @@ +// Deriving a Team's URL slug from a game-written name (TEAMS.md §2.1). +// +// A slug is derived ONCE, at create, and then frozen for the life of the row — +// like `name`, and for the same reason: the Team page URL has to stay stable, and +// a rename is an archive plus a create rather than an edit. + +const MAX_SLUG = 180 // the column is 191; leaves room for a -NN suffix + +/** + * Reduce a name to a URL-safe stem. + * + * Diacritics are folded rather than stripped so "Ünderdark" becomes "underdark" + * and not "nderdark". A name made entirely of characters that do not survive — + * which a guild name genuinely can be, since the game accepts far more than a URL + * does — leaves an empty stem, and the caller substitutes a stable fallback + * rather than minting a Team with no address. + */ +function slugify(name) { + return String(name || '') + .normalize('NFKD') + .replace(/[̀-ͯ]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, MAX_SLUG) + .replace(/-+$/g, '') +} + +/** + * A slug not already taken, given the ones that are. + * + * `taken` must include ARCHIVED teams' slugs, not only active ones. The unique + * key constrains active rows alone, so the database would allow a new Team to + * take a retired Team's slug — and §2.2 promises the retired one stays readable + * at that address, which is what a bookmark or an old Discord link resolves to. + */ +function uniqueSlug(name, taken, { fallback = 'team' } = {}) { + const base = slugify(name) || fallback + const used = new Set(taken) + if (!used.has(base)) return base + // Bounded rather than unbounded: a suffix search that cannot terminate is worse + // than a slug with an id in it, and 999 same-named teams is already absurd. + for (let n = 2; n <= 999; n++) { + const candidate = `${base}-${n}` + if (!used.has(candidate)) return candidate + } + return `${base}-${Date.now().toString(36)}` +} + +module.exports = { slugify, uniqueSlug, MAX_SLUG } diff --git a/server/src/model/teams/teamSync.model.js b/server/src/model/teams/teamSync.model.js new file mode 100644 index 0000000..1847470 --- /dev/null +++ b/server/src/model/teams/teamSync.model.js @@ -0,0 +1,497 @@ +// ── The reconciler ───────────────────────────────────────────────────────── +// +// Core's projection of the module's Teams, kept in step (TEAMS.md §2.4). This is +// the only thing that writes `team_members`, and one of only two things that +// write `teams.status`. +// +// **The four places it refuses to act** are the point of the file, and they are +// all one rule stated four ways: *a result derived from an answer core does not +// trust is never applied.* Anything less specific tends to collapse, under +// maintenance, into "a failed call means no teams" — which is invariant 1's +// failure mode and would empty every roster on the site the first time a sidecar +// restarted. +// +// 1. `getTeams()` not ok → write sync state, touch NOTHING, return. +// 2. ok but empty, core holds ≥1 → quarantine; apply only if the NEXT +// authoritative answer agrees. +// 3. `getTeamMembers()` not ok → that Team's roster untouched and stale; +// the other Teams carry on. +// 4. ok but zero members, had some → the same two-strikes quarantine, per Team. +// +// Gates 2 and 4 exist because an authoritative-looking empty answer during a cold +// start is the one failure indistinguishable from a real wipe. "Every Team on the +// shard disbanded at once" costs one interval of delay to confirm; getting it +// wrong costs every roster on the site. +// +// Events (§2.3) are an OPTIMISATION, never the source of truth. They make the +// common case immediate; reconciliation is what makes it correct. Nothing +// destructive at Team level is ever driven by one — §2.2 scopes archival to an +// authoritative full list, so a `team.disbanded` event schedules a run rather +// than archiving, and a spurious event costs a reconcile instead of a Team. + +const teamsDb = require('./teams.db') +const teamProvider = require('./teamProvider') +const moderation = require('./teamModeration.model') +const { slugify, uniqueSlug } = require('./teamSlug') +const settings = require('../settings/settings.model') +const log = require('../../utils/logger')('teams') + +// At most one run per 30s (§2.4), so a sidecar flapping cannot become a +// reconciliation storm — every flap publishes events, and every event asks for a +// run. +const DEBOUNCE_MS = 30_000 +const DEFAULT_INTERVAL_S = 900 +const MIN_INTERVAL_S = 60 +const INTERVAL_KEY = 'teams_reconcile_interval_s' + +// The six kinds a module may publish (§2.3). Six rather than the four a +// membership-shaped reading suggests, because leadership is its own authority +// path and a leadership change must be expressible without pretending someone +// joined or left. +const EVENT_KINDS = new Set([ + 'team.created', 'team.disbanded', + 'team.member.added', 'team.member.removed', + 'team.leader.added', 'team.leader.removed', +]) + +// Kinds that can only be answered by a full list. `team.created` cannot be +// applied from a delta — a Team built from one has no name, no roster and no +// leaders — and `team.disbanded` must not be, per §2.2. +const RECONCILE_ONLY = new Set(['team.created', 'team.disbanded']) + +// ── Scheduling state (in-process; one provider per deployment) ───────────── + +let running = false +let rerunReason = null +let lastRunAt = 0 +let debounceTimer = null +let pollTimer = null +let started = false + +/** Resolve the poll interval, floored so a bad setting cannot become a hot loop. */ +async function intervalSeconds() { + let raw + try { + raw = await settings.get(INTERVAL_KEY) + } catch { + return DEFAULT_INTERVAL_S + } + const n = Number.parseInt(raw, 10) + if (!Number.isFinite(n) || n < MIN_INTERVAL_S) return DEFAULT_INTERVAL_S + return n +} + +/** + * Backoff, capped at the poll interval (§2.4). + * + * The cap is what keeps this a backoff rather than an outage: a module down for a + * day would otherwise reach a delay measured in weeks and stay stale long after + * it recovered. + */ +function backoffSeconds(consecutiveFailures, intervalS) { + if (!consecutiveFailures) return intervalS + return Math.min(intervalS, 2 ** Math.min(consecutiveFailures, 16) * 15) +} + +// ── Applying one Team ────────────────────────────────────────────────────── + +/** + * Create the row for a Team core has not seen, deriving its slug and screening + * its name against the reserved list (§2.8). + * + * The row is created whatever the screening says, and hidden if it matched. 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 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) { + const taken = await teamsDb.slugsLike(slugify(team.name) || 'team') + const slug = uniqueSlug(team.name, taken) + const screened = await moderation.screenForCreate(team.name) + 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 +} + +/** + * The §2.2 rename rule: same id and a different name is an archive plus a create. + * + * The old row keeps its forum, its activity and its grants, all read-only, and + * points at its successor so the old slug can explain itself instead of 404ing. + * Core never decides whether this is "really" the same team — that judgement is + * the module's, expressed in whether it reuses the external id (§10.5). + */ +async function applyRename(moduleId, existing, team) { + const successorId = await createTeam(moduleId, team) + await teamsDb.archiveTeam(existing.id, 'renamed', successorId) + log.info('team renamed; previous row archived', { + externalId: team.externalId, from: existing.name, to: team.name, archivedId: existing.id, successorId, + }) + return successorId +} + +/** + * Sync one Team's roster and leadership. Gates 3 and 4 live here. + * + * Returns whether the roster was applied, so the caller can tell "synced" from + * "left alone", which is the difference between fresh and stale on that Team's + * page. + */ +async function syncRoster(team) { + const answer = await teamProvider.getTeamMembers(team.external_id) + + // Gate 3. One Team's unanswerable roster is not the other Teams' problem, and + // it is certainly not an empty roster. + if (!answer.ok) { + log.warn('roster left untouched; provider could not answer', { + externalId: team.external_id, reason: answer.reason, + }) + return false + } + + const known = await teamsDb.memberKeys(team.id) + + // Gate 4, the per-Team twin of gate 2. + if (answer.complete && answer.members.length === 0 && known.length > 0) { + if (!team.members_empty_since) { + await teamsDb.setMembersEmptySince(team.id, new Date()) + log.warn('empty roster quarantined; awaiting a second answer', { + externalId: team.external_id, had: known.length, + }) + return false + } + log.warn('empty roster confirmed by a second answer; departing every member', { + externalId: team.external_id, had: known.length, + }) + } else if (team.members_empty_since) { + // Any non-empty answer clears the quarantine. + await teamsDb.setMembersEmptySince(team.id, null) + } + + for (const member of answer.members) { + // eslint-disable-next-line no-await-in-loop + await teamsDb.upsertMember({ + teamId: team.id, + memberKey: member.memberKey, + displayName: member.displayName, + userId: member.userId, + isLeader: member.leader, + rankLabel: member.rankLabel, + online: member.online, + }) + } + + // Removals only from a COMPLETE answer. `complete: false` means "valid but + // partial", so additions and updates apply and nothing is taken away. + if (answer.complete) { + const seen = new Set(answer.members.map((m) => m.memberKey)) + await teamsDb.markDeparted(team.id, known.filter((key) => !seen.has(key))) + } + + // Leadership is a separate question with a separate answer, and a provider that + // cannot answer it leaves the synced value alone rather than demoting everyone. + const leaders = await teamProvider.getTeamLeaders(team.external_id) + if (leaders.ok) { + await teamsDb.setLeaders(team.id, leaders.leaders) + } else { + log.warn('leadership left untouched; provider could not answer', { + externalId: team.external_id, reason: leaders.reason, + }) + } + + await teamsDb.recount(team.id) + await teamsDb.markRosterSynced(team.id) + return true +} + +// ── The run ──────────────────────────────────────────────────────────────── + +/** + * One full reconciliation. Callers use `request()`; this is the body it guards. + * + * Never throws: a reconcile is a background job, and a rejection here would + * surface as an unhandled rejection in the poll timer rather than as anything an + * operator could act on. The failure is recorded where it can be read — in + * `team_sync_state`, which Admin → Teams shows verbatim. + */ +async function runOnce(reason) { + const moduleId = teamProvider.providerModuleId() + if (!moduleId) return { ok: false, reason: 'no team provider is registered' } + + await teamsDb.recordAttempt(moduleId) + const answer = await teamProvider.getTeams() + + // Gate 1. + if (!answer.ok) { + await teamsDb.recordFailure(moduleId, answer.reason) + log.warn('reconcile refused; provider could not answer', { reason: answer.reason, trigger: reason }) + return { ok: false, reason: answer.reason } + } + + const existing = await teamsDb.activeByModule(moduleId) + + // Gate 2. Only a COMPLETE answer can mean "there are no teams" — a partial one + // removes nothing by definition. + if (answer.complete && answer.teams.length === 0 && existing.length > 0) { + const state = await teamsDb.syncState(moduleId) + if (!state || !state.pending_empty_since) { + await teamsDb.setPendingEmpty(moduleId, new Date()) + await teamsDb.recordSuccess(moduleId) + log.warn('empty team list quarantined; awaiting a second answer', { held: existing.length }) + return { ok: true, quarantined: true, applied: 0 } + } + const intervalS = await intervalSeconds() + const waited = (Date.now() - new Date(state.pending_empty_since).getTime()) / 1000 + if (waited < intervalS) { + await teamsDb.recordSuccess(moduleId) + log.warn('empty team list still quarantined', { waitedSeconds: Math.round(waited), intervalS }) + return { ok: true, quarantined: true, applied: 0 } + } + log.warn('empty team list confirmed; archiving every active team', { count: existing.length }) + } else if (answer.teams.length) { + // Any non-empty answer clears the quarantine. + await teamsDb.setPendingEmpty(moduleId, null) + } + + const byExternalId = new Map(existing.map((t) => [t.external_id, t])) + const seen = new Set() + let created = 0 + let renamed = 0 + let rosters = 0 + + for (const team of answer.teams) { + seen.add(team.externalId) + const current = byExternalId.get(team.externalId) + let id + if (!current) { + id = await createTeam(moduleId, team) + created += 1 + } else if (current.name !== team.name) { + id = await applyRename(moduleId, current, team) + renamed += 1 + } else { + id = current.id + await teamsDb.updateTeam(id, { abbr: team.abbr, meta: team.meta }) + } + + // Re-read rather than reusing `current`: a create or a rename has just made a + // row this loop has never seen, and syncRoster reads the quarantine stamp off + // it. Passing a stale object would drop the second strike of gate 4. + const row = await teamsDb.findById(id) + if (row && await syncRoster(row)) rosters += 1 + } + + // Archive what the module no longer lists — the §2.2 disband path, and the only + // one. Guarded by `complete` for the same reason removals are. + let archived = 0 + if (answer.complete) { + for (const team of existing) { + if (seen.has(team.external_id)) continue + await teamsDb.archiveTeam(team.id, 'disbanded') + archived += 1 + log.info('team archived; absent from an authoritative list', { + externalId: team.external_id, name: team.name, + }) + } + } + + // 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) + log.info('reconcile complete', { + trigger: reason, created, renamed, archived, rosters, rehidden, total: answer.teams.length, + }) + return { ok: true, created, renamed, archived, rosters, rehidden } +} + +// ── The public entry points ──────────────────────────────────────────────── + +/** + * Run now, awaited, with the per-module lock held. Admin → Resync uses this, + * because an operator pressing a button is owed the outcome rather than a + * promise that something will happen soon. + * + * A run already in progress is JOINED rather than queued: the caller wants "the + * projection is now current", and a run that started a moment ago delivers that. + */ +async function reconcileNow(reason = 'manual') { + if (running) { + rerunReason = reason + return { ok: true, joined: true } + } + running = true + try { + const result = await runOnce(reason) + lastRunAt = Date.now() + return result + } catch (err) { + log.error('reconcile threw', { message: err.message, trigger: reason }) + return { ok: false, reason: err.message } + } finally { + running = false + const queued = rerunReason + rerunReason = null + // Something asked while this run was in flight, so it saw state this run may + // have been too early to include. Ask again, through the debounce. + if (queued) request({ reason: queued }) + } +} + +/** + * Ask for a reconciliation. Returns immediately and never rejects — this is what + * `ctx.teams.reconcile()` is (§2.3), and a module must not be able to make its + * own call site slow or its own errors someone else's. + */ +function request({ reason = 'module' } = {}) { + if (debounceTimer) return + const since = Date.now() - lastRunAt + if (running) { + rerunReason = reason + return + } + if (since >= DEBOUNCE_MS) { + reconcileNow(reason).catch(() => {}) + return + } + debounceTimer = setTimeout(() => { + debounceTimer = null + reconcileNow(reason).catch(() => {}) + }, DEBOUNCE_MS - since) + // Unreffed for the same reason the provider's deadline is: a pending debounce + // must not hold a shutdown open waiting to do background work. + if (typeof debounceTimer.unref === 'function') debounceTimer.unref() +} + +/** + * Apply a module-published event (§2.3). + * + * Deltas are applied only for a Team core already knows, and only for the four + * kinds a delta can express. Everything else — an unknown Team, a create, a + * disband — asks for a reconciliation instead, because a Team invented from a + * delta has no name, no roster and no leaders, and an archive driven by one is + * destruction on the strength of a message that may simply have been repeated. + */ +async function publish(event) { + const { kind, externalId } = event || {} + if (!EVENT_KINDS.has(kind)) throw new Error(`teams.publish: unknown event kind "${kind}"`) + const id = typeof externalId === 'string' ? externalId.trim() : '' + if (!id) throw new Error(`teams.publish: ${kind} has no externalId`) + + const moduleId = teamProvider.providerModuleId() + if (!moduleId) return + + if (RECONCILE_ONLY.has(kind)) { + request({ reason: kind }) + return + } + + const team = await teamsDb.findActive(moduleId, id) + if (!team) { + request({ reason: `${kind} for an unknown team` }) + return + } + + const memberKey = typeof event.memberKey === 'string' ? event.memberKey.trim() : '' + if (!memberKey) throw new Error(`teams.publish: ${kind} has no memberKey`) + + switch (kind) { + case 'team.member.added': + await teamsDb.upsertMember({ + teamId: team.id, + memberKey, + displayName: typeof event.displayName === 'string' ? event.displayName.trim() : null, + userId: Number.isInteger(event.userId) && event.userId > 0 ? event.userId : null, + isLeader: Boolean(event.leader), + rankLabel: typeof event.rankLabel === 'string' ? event.rankLabel.trim() : null, + online: Boolean(event.online), + }) + break + case 'team.member.removed': + await teamsDb.markDeparted(team.id, [memberKey]) + break + case 'team.leader.added': + case 'team.leader.removed': + // A no-op when the member is unknown: the row is created by the roster, not + // by a leadership delta, and inventing one here would put a member on the + // roster whose only evidence is that someone promoted them. + await teamsDb.setMemberLeader(team.id, memberKey, kind === 'team.leader.added') + break + default: + break + } + + await teamsDb.recount(team.id) + // A delta is a hint that something changed, not a claim to have applied all of + // it, so every one still asks for the run that makes it correct. + request({ reason: kind }) +} + +// ── The poll ─────────────────────────────────────────────────────────────── + +async function scheduleNextPoll() { + const intervalS = await intervalSeconds() + const moduleId = teamProvider.providerModuleId() + let delayS = intervalS + if (moduleId) { + const state = await teamsDb.syncState(moduleId).catch(() => null) + if (state) delayS = backoffSeconds(state.consecutive_failures, intervalS) + } + pollTimer = setTimeout(() => { + reconcileNow('poll').catch(() => {}).then(() => { if (started) scheduleNextPoll().catch(() => {}) }) + }, delayS * 1000) + if (typeof pollTimer.unref === 'function') pollTimer.unref() +} + +/** + * Start the boot reconcile and the poll. Called from the module lifecycle, after + * every module has started — the website may have been down across a whole guild + * war, so the first thing it does on the way up is ask. + */ +async function start() { + if (started) return + started = true + if (!teamProvider.providerModuleId()) { + log.info('no team provider registered; the reconciler stays idle') + return + } + await reconcileNow('boot') + await scheduleNextPoll() +} + +function stop() { + started = false + if (pollTimer) clearTimeout(pollTimer) + if (debounceTimer) clearTimeout(debounceTimer) + pollTimer = null + debounceTimer = null +} + +// Test-only: the scheduler is module-level state, so a test that triggers a run +// has to be able to put it back. +function _reset() { + stop() + running = false + rerunReason = null + lastRunAt = 0 +} + +module.exports = { + reconcileNow, + request, + publish, + start, + stop, + intervalSeconds, + backoffSeconds, + EVENT_KINDS, + DEBOUNCE_MS, + DEFAULT_INTERVAL_S, + _reset, +} diff --git a/server/src/model/teams/teams.db.js b/server/src/model/teams/teams.db.js new file mode 100644 index 0000000..ce4648c --- /dev/null +++ b/server/src/model/teams/teams.db.js @@ -0,0 +1,312 @@ +// SQL for the Team tables. Raw parameterised mariadb, no ORM, per the layered +// backend convention (router → controller → model → db). +// +// This file holds statements only. Every decision about WHETHER to write — the +// four refusal gates, the quarantine, the rename rule — lives in the models above +// it, because a gate expressed as a WHERE clause is a gate nobody can find. + +const { query } = require('../../utils/db') + +// ── teams ────────────────────────────────────────────────────────────────── + +const TEAM_COLUMNS = ` + id, module_id, external_id, name, abbr, slug, status, meta, + member_count, linked_count, online_count, + hidden, hidden_reason, hidden_term, name_reviewed_at, display_name_override, + roster_synced_at, members_empty_since, + succeeded_by, created_at, archived_at, archived_reason` + +/** Every ACTIVE team for a module — the set the reconciler diffs against. */ +async function activeByModule(moduleId) { + return query( + `SELECT ${TEAM_COLUMNS} FROM teams WHERE module_id = ? AND status = 'active' ORDER BY id`, + [moduleId], + ) +} + +/** + * Every ACTIVE team, whichever module owns it. + * + * For the READ side, which must not be keyed on a provider being registered. The + * rows are core's and they outlive the module that filled them — a module + * uninstalled or disabled leaves a projection that is unmaintained, not one that + * stopped existing. Listing by provider made `/teams` empty while + * `/teams/:slug/members` still answered in full, since the lookup goes by slug: + * the index denied a Team that direct URLs served. + */ +async function allActive() { + return query(`SELECT ${TEAM_COLUMNS} FROM teams WHERE status = 'active' ORDER BY id`) +} + +/** The ACTIVE row for an external id, or undefined. At most one, by uq_teams_active. */ +async function findActive(moduleId, externalId) { + const rows = await query( + `SELECT ${TEAM_COLUMNS} FROM teams WHERE module_id = ? AND external_id = ? AND status = 'active'`, + [moduleId, externalId], + ) + return rows[0] +} + +async function findById(id) { + const rows = await query(`SELECT ${TEAM_COLUMNS} FROM teams WHERE id = ?`, [id]) + return rows[0] +} + +/** By slug, ACTIVE or ARCHIVED — an archived Team stays reachable at its old slug (§2.2). */ +async function findBySlug(slug) { + const rows = await query( + `SELECT ${TEAM_COLUMNS} FROM teams WHERE slug = ? ORDER BY (status = 'active') DESC, id DESC LIMIT 1`, + [slug], + ) + return rows[0] +} + +/** + * Slugs already taken, ACTIVE OR ARCHIVED. + * + * The unique key only constrains active rows, and this deliberately checks more + * than the key does: §2.2 promises an archived Team stays readable at its old + * slug, and handing that slug to a new Team would silently break every bookmark + * and Discord link pointing at the old one. + */ +async function slugsLike(base) { + const rows = await query('SELECT slug FROM teams WHERE slug = ? OR slug LIKE ?', [base, `${base}-%`]) + return rows.map((r) => r.slug) +} + +async function insertTeam({ moduleId, externalId, name, abbr, slug, meta, hidden, hiddenReason, hiddenTerm }) { + const res = await query( + `INSERT INTO teams (module_id, external_id, name, abbr, slug, meta, hidden, hidden_reason, hidden_term) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [moduleId, externalId, name, abbr, slug, meta == null ? null : JSON.stringify(meta), + hidden ? 1 : 0, hiddenReason || null, hiddenTerm || null], + ) + return res.insertId +} + +/** Update the mutable fields. `name` and `slug` are absent by design — §2.2 freezes both. */ +async function updateTeam(id, { abbr, meta }) { + await query('UPDATE teams SET abbr = ?, meta = ? WHERE id = ?', + [abbr, meta == null ? null : JSON.stringify(meta), id]) +} + +async function archiveTeam(id, reason, succeededBy = null) { + await query( + `UPDATE teams SET status = 'archived', archived_at = NOW(), archived_reason = ?, succeeded_by = ? + WHERE id = ? AND status = 'active'`, + [reason, succeededBy, id], + ) +} + +/** + * Recompute the three denormalised counts from the projection. + * + * Derived in one statement rather than incremented as rows change, so a missed + * delta can never leave a count drifting from the table it summarises — the count + * is only ever as wrong as the projection is. + */ +async function recount(teamId) { + await query( + `UPDATE teams t SET + member_count = (SELECT COUNT(*) FROM team_members m WHERE m.team_id = t.id AND m.status = 'active'), + linked_count = (SELECT COUNT(*) FROM team_members m WHERE m.team_id = t.id AND m.status = 'active' AND m.user_id IS NOT NULL), + online_count = (SELECT COUNT(*) FROM team_members m WHERE m.team_id = t.id AND m.status = 'active' AND m.online = 1) + WHERE t.id = ?`, + [teamId], + ) +} + +// ── team_members ─────────────────────────────────────────────────────────── + +const MEMBER_COLUMNS = ` + team_id, member_key, display_name, user_id, is_leader, rank_label, online, status, + first_seen_at, last_seen_at, departed_at` + +async function membersByTeam(teamId, { includeDeparted = false } = {}) { + return query( + `SELECT ${MEMBER_COLUMNS} FROM team_members WHERE team_id = ?` + + (includeDeparted ? '' : " AND status = 'active'") + + ' ORDER BY is_leader DESC, display_name, member_key', + [teamId], + ) +} + +async function memberKeys(teamId) { + const rows = await query("SELECT member_key FROM team_members WHERE team_id = ? AND status = 'active'", [teamId]) + return rows.map((r) => r.member_key) +} + +async function findMember(teamId, memberKey) { + const rows = await query(`SELECT ${MEMBER_COLUMNS} FROM team_members WHERE team_id = ? AND member_key = ?`, + [teamId, memberKey]) + return rows[0] +} + +/** The caller's ACTIVE membership of a team, or undefined. Path 1 of §2.5, and only path 1. */ +async function activeByUser(teamId, userId) { + const rows = await query( + `SELECT ${MEMBER_COLUMNS} FROM team_members WHERE team_id = ? AND user_id = ? AND status = 'active'`, + [teamId, userId], + ) + return rows[0] +} + +/** Every ACTIVE membership a user holds, with the team joined on. */ +async function activeTeamsForUser(userId) { + return query( + `SELECT ${TEAM_COLUMNS.split(',').map((c) => `t.${c.trim()}`).join(', ')}, + m.member_key, m.is_leader, m.rank_label, m.display_name AS member_display_name + FROM team_members m JOIN teams t ON t.id = m.team_id + WHERE m.user_id = ? AND m.status = 'active' AND t.status = 'active' + ORDER BY t.name`, + [userId], + ) +} + +/** + * Insert or refresh one member row. + * + * `first_seen_at` is never overwritten, so a member who leaves and rejoins keeps + * the date they first appeared; `status` returns to active on the same statement, + * which is what makes a rejoin a revived row rather than a second one. + * + * **`is_leader` is set on INSERT only, and deliberately not on update.** Path 2 of + * §2.5 is answered by `getTeamLeaders()`, not by the roster — two writers for one + * column is how a refused leadership answer turns into a silent demotion, because + * the roster would already have written `leader: false` before the authoritative + * call was even made. Seeding it on insert means a Team whose leadership call is + * failing is not leaderless from the start; after that, only setLeaders() moves it. + */ +async function upsertMember({ teamId, memberKey, displayName, userId, isLeader, rankLabel, online }) { + await query( + `INSERT INTO team_members (team_id, member_key, display_name, user_id, is_leader, rank_label, online) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + display_name = VALUES(display_name), + user_id = VALUES(user_id), + rank_label = VALUES(rank_label), + online = VALUES(online), + status = 'active', + departed_at = NULL, + last_seen_at = NOW()`, + [teamId, memberKey, displayName, userId, isLeader ? 1 : 0, rankLabel, online ? 1 : 0], + ) +} + +/** Soft-depart the named members. Rows are kept so history and rejoins survive. */ +async function markDeparted(teamId, memberKeys_) { + if (!memberKeys_.length) return + const holes = memberKeys_.map(() => '?').join(', ') + await query( + `UPDATE team_members SET status = 'departed', departed_at = NOW(), online = 0 + WHERE team_id = ? AND status = 'active' AND member_key IN (${holes})`, + [teamId, ...memberKeys_], + ) +} + +/** Set is_leader for a whole team in one pass — the sync's path-2 write. */ +async function setLeaders(teamId, leaderKeys) { + if (leaderKeys.length) { + const holes = leaderKeys.map(() => '?').join(', ') + await query( + `UPDATE team_members SET is_leader = (member_key IN (${holes})) WHERE team_id = ?`, + [...leaderKeys, teamId], + ) + } else { + await query('UPDATE team_members SET is_leader = 0 WHERE team_id = ?', [teamId]) + } +} + +async function setMemberLeader(teamId, memberKey, isLeader) { + await query('UPDATE team_members SET is_leader = ? WHERE team_id = ? AND member_key = ?', + [isLeader ? 1 : 0, teamId, memberKey]) +} + +// ── team_sync_state ──────────────────────────────────────────────────────── + +async function syncState(moduleId) { + const rows = await query( + `SELECT module_id, last_attempt_at, last_success_at, consecutive_failures, last_error, pending_empty_since + FROM team_sync_state WHERE module_id = ?`, + [moduleId], + ) + return rows[0] +} + +async function recordAttempt(moduleId) { + await query( + `INSERT INTO team_sync_state (module_id, last_attempt_at) VALUES (?, NOW()) + ON DUPLICATE KEY UPDATE last_attempt_at = NOW()`, + [moduleId], + ) +} + +async function recordFailure(moduleId, error) { + await query( + `INSERT INTO team_sync_state (module_id, last_attempt_at, consecutive_failures, last_error) + VALUES (?, NOW(), 1, ?) + ON DUPLICATE KEY UPDATE + last_attempt_at = NOW(), + consecutive_failures = consecutive_failures + 1, + last_error = VALUES(last_error)`, + [moduleId, String(error || '').slice(0, 500)], + ) +} + +async function recordSuccess(moduleId) { + await query( + `INSERT INTO team_sync_state (module_id, last_attempt_at, last_success_at, consecutive_failures, last_error) + VALUES (?, NOW(), NOW(), 0, NULL) + ON DUPLICATE KEY UPDATE + last_attempt_at = NOW(), last_success_at = NOW(), consecutive_failures = 0, last_error = NULL`, + [moduleId], + ) +} + +/** Bumped only when a roster was actually APPLIED — never on a refused call. */ +async function markRosterSynced(teamId) { + await query('UPDATE teams SET roster_synced_at = NOW() WHERE id = ?', [teamId]) +} + +/** §2.4 gate 4's per-Team quarantine. `since = null` clears it. */ +async function setMembersEmptySince(teamId, since) { + await query('UPDATE teams SET members_empty_since = ? WHERE id = ?', [since, teamId]) +} + +/** The §2.4 gate-2 quarantine. `since = null` clears it. */ +async function setPendingEmpty(moduleId, since) { + await query( + `INSERT INTO team_sync_state (module_id, pending_empty_since) VALUES (?, ?) + ON DUPLICATE KEY UPDATE pending_empty_since = VALUES(pending_empty_since)`, + [moduleId, since], + ) +} + +module.exports = { + activeByModule, + allActive, + findActive, + findById, + findBySlug, + slugsLike, + insertTeam, + updateTeam, + archiveTeam, + recount, + markRosterSynced, + setMembersEmptySince, + membersByTeam, + memberKeys, + findMember, + activeByUser, + activeTeamsForUser, + upsertMember, + markDeparted, + setLeaders, + setMemberLeader, + syncState, + recordAttempt, + recordFailure, + recordSuccess, + setPendingEmpty, +} diff --git a/server/src/model/teams/teams.model.js b/server/src/model/teams/teams.model.js new file mode 100644 index 0000000..7f09dae --- /dev/null +++ b/server/src/model/teams/teams.model.js @@ -0,0 +1,296 @@ +// ── The Team read model ──────────────────────────────────────────────────── +// +// What the three API tiers are allowed to see (TEAMS.md §2.11), assembled from +// the projection, the resolver and the sync state. +// +// **Two rules shape every function here.** +// +// 1. *Hidden means absent from every public surface* (§2.8.3) — the index, the +// lookup, the roster. Not archived, not deleted, and completely functional for +// its own members. A hidden Team that 404s publicly but answers for a member +// is the intended behaviour, not an inconsistency. +// +// 2. *Staleness is surfaced, never silent* (§2.4). Every public payload carries +// `{ stale, lastSyncAt }`, so a page can say "roster last confirmed 14 minutes +// ago" rather than presenting a stale roster as current. A projection nobody +// can tell is stale is worse than one that is obviously old. +// +// The per-audience FIELD projection of a roster row is the module's, not core's +// (§10.5, §3.3) — the visibility framework and its config are module-owned. This +// phase serves a conservative core projection: a public roster carries in-game +// display names and never a site account id or a game member key. The module's +// rung-aware projection lands with the Team pages in phase 3. + +const teamsDb = require('./teams.db') +const teamProvider = require('./teamProvider') +const access = require('./teamAccess.model') +const teamSync = require('./teamSync.model') + +// Past this multiple of the poll interval a projection is reported stale. Two +// intervals rather than one, so an ordinary late poll does not make every page +// cry wolf — the threshold has to mean "something is wrong", not "a run is due". +const STALE_INTERVALS = 2 + +/** The public shape of a Team. Deliberately small. */ +function publicTeam(row) { + return { + slug: row.slug, + // What is DISPLAYED may have been overridden by staff; what the row IS never + // changes (§2.2, §2.8.3). Public callers only ever see the former. + name: row.display_name_override || row.name, + abbr: row.abbr, + memberCount: row.member_count, + linkedCount: row.linked_count, + onlineCount: row.online_count, + meta: row.meta ?? null, + status: row.status, + createdAt: row.created_at, + rosterSyncedAt: row.roster_synced_at, + ...(row.status === 'archived' ? { archivedAt: row.archived_at, archivedReason: row.archived_reason } : {}), + } +} + +/** + * The public shape of a roster row. + * + * `member_key` and `user_id` are both withheld: the first is a game-internal + * identifier and the second names a site account. `linked` answers the only + * question a public page has — whether this character has an account behind it — + * without publishing which one. + */ +function publicMember(row) { + return { + displayName: row.display_name, + rankLabel: row.rank_label, + isLeader: Boolean(row.is_leader), + online: Boolean(row.online), + linked: row.user_id != null, + } +} + +/** The admin shape: everything, including what a decision overrode. */ +function adminTeam(row) { + return { + id: row.id, + moduleId: row.module_id, + externalId: row.external_id, + slug: row.slug, + name: row.name, + displayName: row.display_name_override || row.name, + displayNameOverride: row.display_name_override, + abbr: row.abbr, + status: row.status, + hidden: Boolean(row.hidden), + hiddenReason: row.hidden_reason, + hiddenTerm: row.hidden_term, + nameReviewedAt: row.name_reviewed_at, + memberCount: row.member_count, + linkedCount: row.linked_count, + onlineCount: row.online_count, + rosterSyncedAt: row.roster_synced_at, + membersEmptySince: row.members_empty_since, + succeededBy: row.succeeded_by, + createdAt: row.created_at, + archivedAt: row.archived_at, + archivedReason: row.archived_reason, + meta: row.meta ?? null, + } +} + +function adminMember(row) { + return { + memberKey: row.member_key, + displayName: row.display_name, + userId: row.user_id, + rankLabel: row.rank_label, + isLeader: Boolean(row.is_leader), + isLeaderSynced: Boolean(row.is_leader_synced), + leaderOverride: row.leader_override || null, + online: Boolean(row.online), + status: row.status, + firstSeenAt: row.first_seen_at, + lastSeenAt: row.last_seen_at, + departedAt: row.departed_at, + } +} + +/** + * Freshness, as every public payload reports it. + * + * With no provider registered there is nothing to be stale ABOUT, so this reports + * `stale: false` and a null timestamp rather than "very stale" — a deployment + * with no game module is not a broken one. + */ +async function syncStatus() { + const moduleId = teamProvider.providerModuleId() + if (!moduleId) return { stale: false, lastSyncAt: null, configured: false } + + const [state, intervalS] = await Promise.all([ + teamsDb.syncState(moduleId), + teamSync.intervalSeconds(), + ]) + const lastSyncAt = state ? state.last_success_at : null + const ageS = lastSyncAt ? (Date.now() - new Date(lastSyncAt).getTime()) / 1000 : Infinity + return { + configured: true, + lastSyncAt, + // Never synced at all is stale: a page must not present an empty projection + // as a confirmed empty shard. + stale: ageS > intervalS * STALE_INTERVALS, + consecutiveFailures: state ? state.consecutive_failures : 0, + } +} + +// ── Public ───────────────────────────────────────────────────────────────── + +async function listPublic({ limit = 50, offset = 0 } = {}) { + // Every active Team, not just the registered provider's. The rows are core's + // and they outlive the module that filled them: keying the index on a provider + // made an uninstalled module's Teams vanish from /teams while + // /teams/:slug/members still served them in full, because the lookup goes by + // slug. `configured: false` is how a client learns the projection is no longer + // being maintained -- an empty list would have said something untrue instead. + const [rows, sync] = await Promise.all([teamsDb.allActive(), syncStatus()]) + const visible = rows.filter((r) => !r.hidden) + return { + teams: visible.slice(offset, offset + limit).map(publicTeam), + total: visible.length, + ...sync, + } +} + +/** + * One Team by slug, for a public caller. + * + * An ARCHIVED Team resolves rather than 404ing (§2.2): a bookmark or a Discord + * link from before a rename must land somewhere that explains itself. A HIDDEN + * one does not resolve at all — that is the difference between retired and + * suppressed. + */ +async function getPublic(slug) { + const row = await teamsDb.findBySlug(slug) + if (!row || row.hidden) return null + const sync = await syncStatus() + const successor = row.succeeded_by ? await teamsDb.findById(row.succeeded_by) : null + return { + ...publicTeam(row), + ...sync, + successor: successor && !successor.hidden + ? { slug: successor.slug, name: successor.display_name_override || successor.name } + : null, + } +} + +async function rosterPublic(slug) { + const row = await teamsDb.findBySlug(slug) + if (!row || row.hidden) return null + const [members, sync] = await Promise.all([ + access.rosterWithOverrides(row.id), + syncStatus(), + ]) + return { members: members.map(publicMember), ...sync, rosterSyncedAt: row.roster_synced_at } +} + +// ── Player ───────────────────────────────────────────────────────────────── + +/** + * The caller's Teams — membership and grants — each with the REASON it is listed. + * + * The two are read from their own tables and merged here rather than by a query + * that unions them, so the reason survives into the payload. `both` is a real + * state and the UI needs it: a member who also holds a historical grant should + * see membership as the current reason without the grant vanishing. + * + * A hidden Team IS listed here. Suppression is a public-surface rule; a member is + * not a member of the public. + */ +async function listForUser(userId) { + const memberships = await teamsDb.activeTeamsForUser(userId) + const byId = new Map() + + for (const row of memberships) { + byId.set(row.id, { ...publicTeam(row), reason: 'membership', isLeader: Boolean(row.is_leader) }) + } + + // Grants are per Team, so the visible set is walked rather than queried the + // other way round; the population is small (a user's Teams), and it keeps path + // 3's read on path 3's table. + const all = await teamsDb.allActive() + for (const row of all) { + // eslint-disable-next-line no-await-in-loop + const resolved = await access.forumAccess(row.id, userId) + if (!resolved.viaGrant) continue + const existing = byId.get(row.id) + if (existing) existing.reason = 'both' + else byId.set(row.id, { ...publicTeam(row), reason: 'grant', isLeader: false }) + } + + return { teams: [...byId.values()], ...(await syncStatus()) } +} + +/** The caller's own resolved access on one Team. */ +async function accessForUser(slug, userId) { + const row = await teamsDb.findBySlug(slug) + if (!row) return null + const resolved = await access.forumAccess(row.id, userId) + return { slug: row.slug, ...resolved } +} + +// ── Admin ────────────────────────────────────────────────────────────────── + +async function listAdmin({ includeArchived = false } = {}) { + const moduleId = teamProvider.providerModuleId() + const rows = await teamsDb.allActive() + const sync = await syncStatus() + const state = moduleId ? await teamsDb.syncState(moduleId) : null + return { + teams: rows.map(adminTeam), + ...sync, + // Shown verbatim on Admin → Teams, including the last error: an operator + // debugging a stale projection needs what the provider actually said. + syncState: state + ? { + moduleId: state.module_id, + lastAttemptAt: state.last_attempt_at, + lastSuccessAt: state.last_success_at, + consecutiveFailures: state.consecutive_failures, + lastError: state.last_error, + pendingEmptySince: state.pending_empty_since, + } + : null, + includeArchived, + } +} + +async function getAdmin(id) { + const row = await teamsDb.findById(id) + if (!row) return null + const [members, grants, pending] = await Promise.all([ + access.rosterWithOverrides(row.id, { includeDeparted: true }), + access.grantLedger(row.id), + // eslint-disable-next-line global-require + require('./teamModeration.model').pendingForTeam(row.id), + ]) + return { + ...adminTeam(row), + members: members.map(adminMember), + grants, + pendingRequests: pending, + } +} + +module.exports = { + listPublic, + getPublic, + rosterPublic, + listForUser, + accessForUser, + listAdmin, + getAdmin, + syncStatus, + publicTeam, + publicMember, + adminTeam, + adminMember, + STALE_INTERVALS, +} diff --git a/server/src/modules/lifecycle.js b/server/src/modules/lifecycle.js index 637df88..3e23a3d 100644 --- a/server/src/modules/lifecycle.js +++ b/server/src/modules/lifecycle.js @@ -170,6 +170,15 @@ async function boot({ modules, model } = {}) { }) } } + + // The Team reconciler's boot trigger (TEAMS.md §2.4), last — after every module + // has started, because the provider is registered by a module and a module that + // warms a cache in onBoot must be allowed to finish before it is asked anything. + // + // `safe` for the same reason every step above uses it: an unreachable provider + // is a stale projection, never a site that will not start. + // eslint-disable-next-line global-require + await safe('starting the team reconciler', () => require('../model/teams/teamSync.model').start()) } /** Reject if `fn`'s promise has not settled within `ms`. */ diff --git a/server/src/modules/loader.js b/server/src/modules/loader.js index 53c7aad..c1d4370 100644 --- a/server/src/modules/loader.js +++ b/server/src/modules/loader.js @@ -119,6 +119,7 @@ function buildCtx(id, moduleRoot) { const uploads = require('../router/v1/admin/imageUpload') const activity = require('../model/activity/activity.model') const users = require('../model/users/users.model') + const teams = require('../model/teams/teamSync.model') const { makeLimiter, accountChangeLimiter } = require('../middleware/rateLimit') /* eslint-enable global-require */ @@ -174,6 +175,31 @@ function buildCtx(id, moduleRoot) { // a place nobody looks. `list` stays core's: reading the log is the admin // panel's job, and it spans every actor. activity: { log: activity.log }, + // Teams (API 1.6.0, TEAMS.md §2.3). Push, to the pull the provider answers. + // + // Both are fire-and-forget by contract. `publish` is an OPTIMISATION — it + // makes a membership change visible at once — and `reconcile` is a REQUEST, + // debounced and never awaited, so a module cannot make its own call site slow + // or turn a background failure into its own error. Correctness comes from the + // reconciler either way; these only decide how soon. + // + // There is deliberately no reader here. A module answers questions about + // Teams; it does not ask them. Every Team table is core-internal (§10.3), and + // a `getTeamRoster` on ctx would be core offering to read back the module's + // own answer — which is the module's data, in the module's own store. + teams: { + publish: (event) => teams.publish(event), + reconcile: (opts) => teams.request(opts), + // §4's activity feed, which lands with the Team pages in phase 3. Declared + // in 1.6.0 alongside the rest of the Team surface; calling it before phase 3 + // throws rather than silently accepting items into a table that does not + // exist yet. + activity: { + push: () => { + throw new Error('ctx.teams.activity.push is not available until the Team activity feed lands (TEAMS.md §4)') + }, + }, + }, // One function, for one caller: the `admin.users.detail` slot router needs // the user its prefix names. Narrowed like `ctx.posts` — the users model // exports creation, role changes and password handling, none of which is a @@ -240,6 +266,23 @@ function buildApi(record) { record.staged.registerNotificationStreams(streams) }, registerAnnounceLeg: record.staged.registerAnnounceLeg, + // The Team provider (API 1.6.0, TEAMS.md §2.3). Unlike every registration + // above, this one is core CALLING THE MODULE and waiting for an answer — the + // same direction registerAnnounceLeg's dispatch already goes, which is why it + // is modelled on it rather than invented. `once` because a module registering + // twice means two answers to a question that has one. + registerTeamProvider(provider) { + once('registerTeamProvider') + record.staged.registerTeamProvider(provider) + }, + // Declared in 1.6.0 with the rest of the Team surface; the bot half that + // executes a command lands in phase 7 (§7.1). Present and throwing rather + // than absent, so a module written against the published version fails at + // registration with a sentence naming the phase, instead of at whatever + // moment someone first types the command. + registerSlashCommands() { + throw new Error('api.registerSlashCommands is not available until Discord slash commands land (TEAMS.md §7.1)') + }, // The two lifecycle hooks (§2.5). Registered here, dispatched from // lifecycle.js — this file runs with no database and the hooks run with one. // Both are optional: a module with no warm-up and nothing to close simply diff --git a/server/src/modules/registries.js b/server/src/modules/registries.js index 938765f..7165f8e 100644 --- a/server/src/modules/registries.js +++ b/server/src/modules/registries.js @@ -58,6 +58,16 @@ const legs = new Map() // a collision with a name attached rather than a silently doubled side effect. const postHooks = new Map() +// { owner, getTeams, getTeamMembers, getTeamLeaders } or null — the Team provider +// (API 1.6.0, TEAMS.md §2.3). +// +// A SINGLE value rather than a Map, unlike every registry above it, and that is +// the contract: one provider per deployment. Teams have one authoritative source +// by construction — two modules answering "what teams exist" would produce two +// disjoint sets under one `teams` table with no rule for merging them, so a +// second registration is a collision rather than an addition. +let teamProvider = null + let coreRegistered = false // Stream ids that predate the module system and may not carry their owner's @@ -196,6 +206,14 @@ const announceLegIds = () => [...legs.keys()] /** One leg, or null. */ const announceLeg = (leg) => legs.get(leg) || null +// ── Team provider (TEAMS.md §2.3) ────────────────────────────────────────── + +/** The registered provider, or null when no module supplies one. */ +const registeredTeamProvider = () => teamProvider + +/** Is there a Team provider at all? Read by the reconciler and the read API. */ +const hasTeamProvider = () => teamProvider !== null + // ── Shape checks, run the moment a registrant calls ──────────────────────── // // Split from the collision checks below on the same line PR 3 drew through @@ -225,6 +243,23 @@ function checkLegShape(entry) { return { leg, label: label || leg, dispatch, classify } } +// All three methods are REQUIRED, with no optional half. A provider that could +// list Teams but not their members would leave core holding Teams it can never +// populate, and the reconciler has no sensible behaviour for that — it is not the +// same as a call that fails, which is staleness and already handled (§2.4). A +// module unable to answer one of the three answers `{ ok: false }` at call time. +function checkTeamProviderShape(entry) { + const provider = entry || {} + const out = {} + for (const name of ['getTeams', 'getTeamMembers', 'getTeamLeaders']) { + if (typeof provider[name] !== 'function') { + throw new Error(`registerTeamProvider: ${name}() is missing or not a function`) + } + out[name] = provider[name] + } + return out +} + /** * `registerPostHook({ onSaved, onDeleted })` — both optional, at least one * required. A registration with neither is a subscription that can never fire, @@ -265,7 +300,7 @@ function checkExtensionShape(slot, router, specFile) { * `allStreams()` / `announceLeg()` / the slot routers until `apply()`. */ function stage(owner) { - const staged = { owner, streams: [], legs: [], extensions: [], postHooks: [] } + const staged = { owner, streams: [], legs: [], extensions: [], postHooks: [], teamProviders: [] } return { staged, registerNotificationStreams(entries) { @@ -281,6 +316,9 @@ function stage(owner) { registerPostHook(entry) { staged.postHooks.push(checkPostHookShape(entry)) }, + registerTeamProvider(entry) { + staged.teamProviders.push(checkTeamProviderShape(entry)) + }, } } @@ -293,7 +331,14 @@ function stage(owner) { * PR 2 learned to protect (mounting inside the scan loop made every collision * look like it was with core). */ -function apply({ owner, streams: newStreams, legs: newLegs, extensions: newExtensions, postHooks: newPostHooks = [] }) { +function apply({ + owner, + streams: newStreams, + legs: newLegs, + extensions: newExtensions, + postHooks: newPostHooks = [], + teamProviders: newTeamProviders = [], +}) { // ── validate ── const seenStreams = new Set() for (const s of newStreams) { @@ -332,6 +377,11 @@ function apply({ owner, streams: newStreams, legs: newLegs, extensions: newExten throw new Error(`"${owner}" already registered a post hook`) } + if (newTeamProviders.length > 1) throw new Error(`"${owner}" registered more than one team provider`) + if (newTeamProviders.length && teamProvider) { + throw new Error(`a team provider is already registered by "${teamProvider.owner}"`) + } + // ── commit — nothing below can fail ── for (const s of newStreams) { streamOwners.set(s.id, owner) @@ -345,6 +395,7 @@ function apply({ owner, streams: newStreams, legs: newLegs, extensions: newExten entry.router.use(x.router) } for (const h of newPostHooks) postHooks.set(owner, h) + for (const p of newTeamProviders) teamProvider = { owner, ...p } } // ── Core's own registrations ─────────────────────────────────────────────── @@ -410,6 +461,7 @@ function _reset() { streamOwners.clear() legs.clear() postHooks.clear() + teamProvider = null coreRegistered = false } @@ -427,6 +479,8 @@ module.exports = { announceLeg, postHookEntries, dispatchPostHook, + registeredTeamProvider, + hasTeamProvider, stage, apply, registerCore, diff --git a/server/src/modules/version.js b/server/src/modules/version.js index 6ce1362..b7ef857 100644 --- a/server/src/modules/version.js +++ b/server/src/modules/version.js @@ -9,6 +9,21 @@ // Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and // has nothing to say about a website module) and from any module's own version. +// 1.6.0 — the Team surface (docs/website/TEAMS.md Part 11). Additions only, so +// minor: `api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders })`, +// `ctx.teams.publish(event)`, `ctx.teams.reconcile({ reason })`, +// `ctx.teams.activity.push(items)`, `api.registerSlashCommands([...])`, and the +// client slots `team.overview` / `team.member.row`. module-uo's `coreApi: +// "^1.3.0"` still resolves. +// +// **The number covers the whole surface; the members arrive by phase.** The three +// this phase implements are live. `activity.push` lands with the Team activity +// feed (§4, phase 3) and `registerSlashCommands` with the Discord commands (§7.1, +// phase 7) — until then each is present and THROWS rather than being absent or, +// worse, silently accepting data into a table that does not exist. MODULE_API.md +// names the phase against each member, so a module author reads what is callable +// today rather than discovering it at runtime. +// // 1.5.0 — a CLIENT addition: `PublicLayout` takes an optional `shell` prop that // renders the page body wrapper core's own pages write by hand (MODULE_API.md // §3.4). Minor, not major: §3.4 makes *changing* a kit component's props a major @@ -44,6 +59,6 @@ // an admin action a module performs belongs in core's one audit log, the // extension slot needs the user its prefix names, and §2.7 forbids a module // reading core's `APP_BASE_URL` for itself. Additions only, so minor. -const MODULE_API_VERSION = '1.5.0' +const MODULE_API_VERSION = '1.6.0' module.exports = { MODULE_API_VERSION } diff --git a/server/src/router/v1/admin/index.js b/server/src/router/v1/admin/index.js index 24c5fed..cc3e9aa 100644 --- a/server/src/router/v1/admin/index.js +++ b/server/src/router/v1/admin/index.js @@ -31,6 +31,7 @@ const emailRouter = require('./email.router') const discordBotRouter = require('./discordBot.router') const settingsRouter = require('./settings.router') const modulesRouter = require('./modules.router') +const teamsRouter = require('./teams.router') const dashboardRouter = require('./dashboard.router') const adminRouter = express.Router() @@ -79,6 +80,11 @@ adminRouter.use('/settings', settingsRouter) // here alongside the other configuration capabilities, and admin-only per route // rather than at this line, so the gate sits next to what it is guarding. adminRouter.use('/modules', modulesRouter) +// Teams. Staff-wide, like /activity: a moderator runs the reserved-name review +// queue. The three actions that PUBLISH untrusted game-sourced strings are gated +// per request inside the controller, not per route — a moderator may call them, +// and calling them files a request rather than applying one (TEAMS.md §2.9). +adminRouter.use('/teams', teamsRouter) // The two singletons that own no path segment of their own: GET /dashboard and // PUT /site-mode. Mounted at the group root, last, exactly where the residual diff --git a/server/src/router/v1/admin/teams.controller.js b/server/src/router/v1/admin/teams.controller.js new file mode 100644 index 0000000..00627f1 --- /dev/null +++ b/server/src/router/v1/admin/teams.controller.js @@ -0,0 +1,211 @@ +// Admin · Teams — the staff surface (TEAMS.md §2.11). +// +// The role split inside this file is the §2.9 gate, and it is enforced HERE +// rather than in the router, because it is not a matter of which routes a role +// may call: a moderator may call all of them, and three of them mean something +// different when they do. `requestOrApply` is what decides, from the caller's +// live role, whether an action applies or is filed for approval. + +const teams = require('../../../model/teams/teams.model') +const moderation = require('../../../model/teams/teamModeration.model') +const access = require('../../../model/teams/teamAccess.model') +const teamSync = require('../../../model/teams/teamSync.model') +const teamsDb = require('../../../model/teams/teams.db') +const activity = require('../../../model/activity/activity.model') + +const log = require('../../../utils/logger')('teams') + +const fail = (res, err, what) => { + log.error(`admin teams: ${what} failed`, { message: err.message }) + return res.status(500).json({ message: 'Internal Server Error' }) +} + +/** Translate a model result's { ok, status, error } into a response. */ +const send = (res, result, body = { ok: true }) => + (result.ok ? res.json({ ...body, ...result }) : res.status(result.status || 400).json({ message: result.error })) + +async function listTeams(req, res) { + try { + return res.json(await teams.listAdmin({ includeArchived: req.query.archived === '1' })) + } catch (err) { + return fail(res, err, 'list') + } +} + +async function getTeam(req, res) { + try { + const team = await teams.getAdmin(Number(req.params.id)) + if (!team) return res.status(404).json({ message: 'Team not found' }) + return res.json(team) + } catch (err) { + return fail(res, err, 'get') + } +} + +/** + * The operator's escape hatch. + * + * Awaited rather than fire-and-forget: someone who pressed a button is owed the + * outcome, including the provider's error when it refused. `ctx.teams.reconcile()` + * is the debounced, unawaited path — this is not that. + */ +async function resync(req, res) { + try { + const result = await teamSync.reconcileNow('admin') + await activity.log({ req, action: 'team.resync', detail: `${req.user.username} (#${req.user.id}) ran a Team resync` }) + return res.json(result) + } catch (err) { + return fail(res, err, 'resync') + } +} + +async function archive(req, res) { + try { + const id = Number(req.params.id) + const team = await teamsDb.findById(id) + if (!team) return res.status(404).json({ message: 'Team not found' }) + await teamsDb.archiveTeam(id, 'staff') + await activity.log({ + req, + action: 'team.archive', + detail: `${req.user.username} (#${req.user.id}) archived team "${team.name}" (#${id})` + + `${req.body.reason ? `: "${req.body.reason}"` : ''}`, + }) + return res.json({ ok: true }) + } catch (err) { + return fail(res, err, 'archive') + } +} + +async function grants(req, res) { + try { + return res.json({ grants: await access.grantLedger(Number(req.params.id)) }) + } catch (err) { + return fail(res, err, 'grants') + } +} + +// ── Leadership overrides (§2.5.1) — NOT gated ───────────────────────────── + +async function setLeaderOverride(req, res) { + try { + const id = Number(req.params.id) + const team = await teamsDb.findById(id) + if (!team) return res.status(404).json({ message: 'Team not found' }) + + const { memberKey, effect, reason } = req.body + await access.setLeaderOverride({ + teamId: id, + memberKey, + effect, + actorUserId: req.user.id, + actorUsername: req.user.username, + reason: reason || null, + }) + await activity.log({ + req, + action: 'team.leader.override', + detail: `${req.user.username} (#${req.user.id}) set a "${effect}" leadership override on ` + + `${memberKey} in team "${team.name}" (#${id})${reason ? `: "${reason}"` : ''}`, + }) + return res.json({ ok: true }) + } catch (err) { + return fail(res, err, 'leader-override') + } +} + +async function clearLeaderOverride(req, res) { + try { + const id = Number(req.params.id) + const removed = await access.clearLeaderOverride(id, req.params.memberKey) + if (!removed) return res.status(404).json({ message: 'No such override' }) + await activity.log({ + req, + action: 'team.leader.override', + detail: `${req.user.username} (#${req.user.id}) cleared the leadership override on ` + + `${req.params.memberKey} in team #${id}`, + }) + return res.json({ ok: true }) + } catch (err) { + return fail(res, err, 'leader-override') + } +} + +// ── The three gated actions, plus the ungated hide (§2.9) ───────────────── + +async function unhide(req, res) { + try { + return send(res, await moderation.requestOrApply({ + req, actor: req.user, teamId: Number(req.params.id), action: 'unhide', reason: req.body.reason, + })) + } catch (err) { + return fail(res, err, 'unhide') + } +} + +async function hide(req, res) { + try { + return send(res, await moderation.hide({ + req, actor: req.user, teamId: Number(req.params.id), reason: req.body.reason, + })) + } catch (err) { + return fail(res, err, 'hide') + } +} + +async function displayName(req, res) { + try { + const { displayName: value, reason } = req.body + // An empty string is how a UI says "clear it", and clearing is its own gated + // action rather than an override set to nothing — otherwise the audit line + // would read as though someone published a blank name. + const action = value ? 'display_name_override' : 'clear_display_name_override' + return send(res, await moderation.requestOrApply({ + req, actor: req.user, teamId: Number(req.params.id), action, payload: { displayName: value || null }, reason, + })) + } catch (err) { + return fail(res, err, 'display-name') + } +} + +async function reviewQueue(req, res) { + try { + return res.json({ teams: await moderation.reviewQueue() }) + } catch (err) { + return fail(res, err, 'review queue') + } +} + +async function listRequests(req, res) { + try { + return res.json({ requests: await moderation.listRequests({ status: req.query.status || 'pending' }) }) + } catch (err) { + return fail(res, err, 'requests') + } +} + +async function decideRequest(req, res) { + try { + return send(res, await moderation.decide({ + req, actor: req.user, requestId: Number(req.params.id), status: req.body.status, note: req.body.note, + })) + } catch (err) { + return fail(res, err, 'decide') + } +} + +module.exports = { + listTeams, + getTeam, + resync, + archive, + grants, + setLeaderOverride, + clearLeaderOverride, + unhide, + hide, + displayName, + reviewQueue, + listRequests, + decideRequest, +} diff --git a/server/src/router/v1/admin/teams.router.js b/server/src/router/v1/admin/teams.router.js new file mode 100644 index 0000000..33d2715 --- /dev/null +++ b/server/src/router/v1/admin/teams.router.js @@ -0,0 +1,223 @@ +// Admin · Teams — sync state, the review queue, the approval queue, and the staff +// actions on a Team (TEAMS.md §2.11). +// +// Mounted at /api/v1/admin/teams by admin/index.js, which already applied +// `noindex, isLoggedIn, staffOnly`. Staff-wide, like /admin/activity: a moderator +// runs the review queue, and the three actions that PUBLISH untrusted +// game-sourced strings are gated per request inside the controller rather than +// per route here — a moderator may call them, and calling them files a request +// instead of applying one. +// +// **Declaration order matters in this file.** `/review`, `/requests` and `/resync` +// are literal paths that would otherwise be captured by `/:id`, so every literal +// route is declared before the first :param route. Express is first-match-wins and +// a `/:id` ahead of `/review` would silently turn a queue into a lookup for a Team +// whose id is "review". + +const express = require('express') +const { body, param, query } = require('express-validator') + +const ctrl = require('./teams.controller') +const validate = require('../../../middleware/validate') + +const teamsRouter = express.Router() + +// ── Literal paths, first ─────────────────────────────────────────────────── + +teamsRouter.get( + '/', + // #swagger.tags = ['Admin · Teams'] + // #swagger.summary = 'List Teams with sync state' + // #swagger.description = 'Includes hidden Teams and the module’s sync state verbatim — last attempt, last success, consecutive failures and the last error — which is what an operator debugging a stale projection needs.' + // #swagger.parameters['archived'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Set to 1 to include archived Teams.' } + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Teams and sync state', content: { "application/json": { schema: { $ref: "#/components/schemas/AdminTeamList" } } } } */ + query('archived').optional().isIn(['0', '1']), + validate, + ctrl.listTeams, +) + +teamsRouter.post( + '/resync', + // #swagger.tags = ['Admin · Teams'] + // #swagger.summary = 'Run a reconciliation now' + // #swagger.description = 'Awaited, so the response carries the outcome including the provider’s own error when it refused. The four refusal gates still apply — a manual resync cannot make core act on an answer it does not trust.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'The reconciliation result', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamResyncResult" } } } } */ + ctrl.resync, +) + +teamsRouter.get( + '/review', + // #swagger.tags = ['Admin · Teams'] + // #swagger.summary = 'The reserved-name review queue' + // #swagger.description = 'Teams auto-hidden because their name matched a reserved term, each showing which term matched. A Team a human has already ruled on leaves the queue and is never re-hidden by a later sweep.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Auto-hidden Teams awaiting review', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamReviewQueue" } } } } */ + ctrl.reviewQueue, +) + +teamsRouter.get( + '/requests', + // #swagger.tags = ['Admin · Teams'] + // #swagger.summary = 'The moderation approval queue' + // #swagger.description = 'Requests filed by moderators for the three actions that publish untrusted game-sourced strings. Decided rows are kept — the record that a moderator asked to publish a name and an admin refused is the part worth having.' + // #swagger.parameters['status'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'pending (default) | approved | rejected | withdrawn | all' } + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Moderation requests', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamRequestQueue" } } } } */ + query('status').optional().isIn(['pending', 'approved', 'rejected', 'withdrawn', 'all']), + validate, + ctrl.listRequests, +) + +teamsRouter.post( + '/requests/:id/decide', + // #swagger.tags = ['Admin · Teams'] + // #swagger.summary = 'Approve or reject a moderation request (admin only)' + // #swagger.description = 'Admin only, checked live against the database rather than from a token claim. Approving applies the action; rejecting keeps the row and changes nothing. A request already decided returns 409, so two admins deciding at once cannot double-apply.' + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Request id.' } + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamDecideRequest" } } } } */ + /* #swagger.responses[200] = { description: 'Decided', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */ + /* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */ + /* #swagger.responses[403] = { description: 'Only an admin may decide a request', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[404] = { description: 'No such request', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[409] = { description: 'Already decided', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt({ min: 1 }).toInt(), + body('status').isIn(['approved', 'rejected']), + body('note').optional().isString().trim().isLength({ max: 255 }), + validate, + ctrl.decideRequest, +) + +// ── :id paths ────────────────────────────────────────────────────────────── + +teamsRouter.get( + '/:id', + // #swagger.tags = ['Admin · Teams'] + // #swagger.summary = 'Get one Team, with its roster, grant ledger and pending requests' + // #swagger.description = 'The roster carries the resolved leadership and what the game actually said, so an override is visible as a decision rather than presented as fact. Departed members are included.' + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' } + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'The Team', content: { "application/json": { schema: { $ref: "#/components/schemas/AdminTeam" } } } } */ + /* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt({ min: 1 }).toInt(), + validate, + ctrl.getTeam, +) + +teamsRouter.get( + '/:id/grants', + // #swagger.tags = ['Admin · Teams'] + // #swagger.summary = 'The full forum-grant ledger for a Team, revoked rows included' + // #swagger.description = 'The structured record the access resolver reads. The grant/revoke flow itself lands in the forum phase; this is the read side.' + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' } + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'The grant ledger', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamGrantLedger" } } } } */ + param('id').isInt({ min: 1 }).toInt(), + validate, + ctrl.grants, +) + +teamsRouter.post( + '/:id/archive', + // #swagger.tags = ['Admin · Teams'] + // #swagger.summary = 'Archive a Team (staff)' + // #swagger.description = 'Not gated: archiving withdraws a Team from public surfaces rather than publishing anything.' + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' } + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: false, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamReasonRequest" } } } } */ + /* #swagger.responses[200] = { description: 'Archived', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */ + /* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt({ min: 1 }).toInt(), + body('reason').optional().isString().trim().isLength({ max: 255 }), + validate, + ctrl.archive, +) + +teamsRouter.post( + '/:id/hide', + // #swagger.tags = ['Admin · Teams'] + // #swagger.summary = 'Hide a Team from public surfaces (staff)' + // #swagger.description = '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.' + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' } + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: false, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamReasonRequest" } } } } */ + /* #swagger.responses[200] = { description: 'Hidden', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */ + /* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt({ min: 1 }).toInt(), + body('reason').optional().isString().trim().isLength({ max: 255 }), + validate, + ctrl.hide, +) + +teamsRouter.post( + '/:id/unhide', + // #swagger.tags = ['Admin · Teams'] + // #swagger.summary = 'Un-hide a Team — admin applies, moderator requests' + // #swagger.description = 'One of the three gated actions: it publishes a name that tripped the impersonation list. An admin applies it at once; a moderator files a pending request and nothing changes publicly until an admin approves.' + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' } + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: false, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamReasonRequest" } } } } */ + /* #swagger.responses[200] = { description: 'Applied, or filed for approval — see `pending`', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamModerationResult" } } } } */ + /* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt({ min: 1 }).toInt(), + body('reason').optional().isString().trim().isLength({ max: 255 }), + validate, + ctrl.unhide, +) + +teamsRouter.post( + '/:id/display-name', + // #swagger.tags = ['Admin · Teams'] + // #swagger.summary = 'Set or clear a Team’s display name — admin applies, moderator requests' + // #swagger.description = 'Gated for the same reason as un-hiding: it substitutes free text into the same public surfaces. Identity is untouched — the Team’s `name` stays frozen for the life of the row, and only what is rendered changes. An empty displayName clears the override.' + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' } + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamDisplayNameRequest" } } } } */ + /* #swagger.responses[200] = { description: 'Applied, or filed for approval — see `pending`', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamModerationResult" } } } } */ + /* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */ + /* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt({ min: 1 }).toInt(), + body('displayName').optional({ nullable: true }).isString().trim().isLength({ max: 160 }), + body('reason').optional().isString().trim().isLength({ max: 255 }), + validate, + ctrl.displayName, +) + +teamsRouter.post( + '/:id/leader-override', + // #swagger.tags = ['Admin · Teams'] + // #swagger.summary = 'Grant or deny leadership for one member (staff)' + // #swagger.description = 'Applied on top of the synced value at READ time; the projection is never mutated. That is what makes an override survive a resync — one written into team_members would be undone by the next reconciliation. Not gated: it publishes no game-sourced string.' + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' } + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamLeaderOverrideRequest" } } } } */ + /* #swagger.responses[200] = { description: 'Override set', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */ + /* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */ + /* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt({ min: 1 }).toInt(), + body('memberKey').isString().trim().isLength({ min: 1, max: 191 }), + body('effect').isIn(['grant', 'deny']), + body('reason').optional().isString().trim().isLength({ max: 255 }), + validate, + ctrl.setLeaderOverride, +) + +teamsRouter.delete( + '/:id/leader-override/:memberKey', + // #swagger.tags = ['Admin · Teams'] + // #swagger.summary = 'Clear a leadership override (staff)' + // #swagger.description = 'The member reverts to whatever the game says at the next read; nothing in the projection changes, because nothing in it was ever changed.' + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' } + // #swagger.parameters['memberKey'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The module’s member key.' } + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Override cleared', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */ + /* #swagger.responses[404] = { description: 'No such override', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt({ min: 1 }).toInt(), + param('memberKey').isString().trim().isLength({ min: 1, max: 191 }), + validate, + ctrl.clearLeaderOverride, +) + +module.exports = teamsRouter diff --git a/server/src/router/v1/player/index.js b/server/src/router/v1/player/index.js index c02a1ca..ffe8298 100644 --- a/server/src/router/v1/player/index.js +++ b/server/src/router/v1/player/index.js @@ -26,6 +26,7 @@ const noindex = require('../../../middleware/noindex') const accountRouter = require('./account.router') const appealsRouter = require('./appeals.router') +const teamsRouter = require('./teams.router') const playerRouter = express.Router() @@ -39,5 +40,6 @@ playerRouter.use(noindex, requireAuth) playerRouter.use('/account', accountRouter) playerRouter.use('/appeals', appealsRouter) +playerRouter.use('/teams', teamsRouter) module.exports = playerRouter diff --git a/server/src/router/v1/player/teams.controller.js b/server/src/router/v1/player/teams.controller.js new file mode 100644 index 0000000..a37447f --- /dev/null +++ b/server/src/router/v1/player/teams.controller.js @@ -0,0 +1,28 @@ +// Player · Teams — self-scoped reads. Neither handler takes an identity from the +// caller; both use req.user.id, which the tier's requireAuth has already proved. + +const teams = require('../../../model/teams/teams.model') + +const log = require('../../../utils/logger')('teams') + +async function listMine(req, res) { + try { + return res.json(await teams.listForUser(req.user.id)) + } catch (err) { + log.error('player teams: list failed', { message: err.message }) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +async function getMyAccess(req, res) { + try { + const resolved = await teams.accessForUser(req.params.slug, req.user.id) + if (!resolved) return res.status(404).json({ message: 'Team not found' }) + return res.json(resolved) + } catch (err) { + log.error('player teams: access failed', { message: err.message }) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +module.exports = { listMine, getMyAccess } diff --git a/server/src/router/v1/player/teams.router.js b/server/src/router/v1/player/teams.router.js new file mode 100644 index 0000000..4f1bc53 --- /dev/null +++ b/server/src/router/v1/player/teams.router.js @@ -0,0 +1,46 @@ +// Player · Teams — the caller's own Teams and their own access on one. +// +// Mounted at /api/v1/player/teams by player/index.js, which already applied +// `noindex, requireAuth`. No extra gate: both handlers are self-scoped to +// req.user.id and neither takes a user id from the caller. +// +// **Staff are a superset of players.** This group is open to any authenticated +// account, not just role 'player' — a moderator is in guilds too, and gating on +// the role would 403 them off their own Teams. That mistake has been made here +// once already (see player/index.js). +// +// Leader-exercised actions — granting forum access — land in phase 4 and will +// live under this same prefix rather than under /admin: a leader is a player, and +// the /admin tier gate is requireRole('admin','editor','moderator'), so putting a +// leader endpoint behind it would mean widening that gate. + +const express = require('express') + +const ctrl = require('./teams.controller') + +const teamsRouter = express.Router() + +teamsRouter.get( + '/', + // #swagger.tags = ['Player · Teams'] + // #swagger.summary = 'List the caller’s Teams, with the reason for each' + // #swagger.description = 'Membership and forum grants are separate authority paths, so each Team carries `reason`: membership | grant | both. A Team hidden from public surfaces is still listed here — suppression is a public-surface rule, and a member is not a member of the public.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'The caller’s Teams', content: { "application/json": { schema: { $ref: "#/components/schemas/PlayerTeamList" } } } } */ + /* #swagger.responses[403] = { description: 'Account not active (disabled/banned)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + ctrl.listMine, +) + +teamsRouter.get( + '/:slug/access', + // #swagger.tags = ['Player · Teams'] + // #swagger.summary = 'The caller’s own resolved access on one Team' + // #swagger.description = 'Reports viaMembership and viaGrant separately, and keeps both when both hold: the UI presents membership as the current reason while the grant survives as audit history.' + // #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' } + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'The caller’s access', content: { "application/json": { schema: { $ref: "#/components/schemas/PlayerTeamAccess" } } } } */ + /* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + ctrl.getMyAccess, +) + +module.exports = teamsRouter diff --git a/server/src/router/v1/public/index.js b/server/src/router/v1/public/index.js index 0a1139b..fb7f636 100644 --- a/server/src/router/v1/public/index.js +++ b/server/src/router/v1/public/index.js @@ -21,6 +21,7 @@ const postsRouter = require('./posts.router') const wikiRouter = require('./wiki.router') const pagesRouter = require('./pages.router') const modulesRouter = require('./modules.router') +const teamsRouter = require('./teams.router') const siteRouter = require('./site.router') const publicRouter = express.Router() @@ -36,6 +37,10 @@ publicRouter.use('/pages', pagesRouter) // /modules unclaimable by a module. Never site-mode gated: a client must be able // to feature-detect while the site is in maintenance. publicRouter.use('/modules', modulesRouter) +// Teams. A core prefix, not a module's: the entity is core's even though a module +// is what populates it (TEAMS.md §10.3). Site-mode gated per route, like the +// content above it. +publicRouter.use('/teams', teamsRouter) // The four singletons that own no path segment of their own: /settings, /status, // /version and /contact. Mounted at the group root, last — safe only because diff --git a/server/src/router/v1/public/teams.controller.js b/server/src/router/v1/public/teams.controller.js new file mode 100644 index 0000000..701cc9b --- /dev/null +++ b/server/src/router/v1/public/teams.controller.js @@ -0,0 +1,48 @@ +// Public · Teams — the anonymous read surface (TEAMS.md §2.11). +// +// Every handler here is a projection over core's own tables; nothing calls the +// module. A Team page must render while the shard is down, showing a roster +// marked stale, because that is what the projection is for. + +const teams = require('../../../model/teams/teams.model') + +const log = require('../../../utils/logger')('teams') + +const fail = (res, err, what) => { + log.error(`public teams: ${what} failed`, { message: err.message }) + return res.status(500).json({ message: 'Internal Server Error' }) +} + +async function listTeams(req, res) { + try { + const limit = Math.min(Number.parseInt(req.query.limit, 10) || 50, 200) + const offset = Math.max(Number.parseInt(req.query.offset, 10) || 0, 0) + return res.json(await teams.listPublic({ limit, offset })) + } catch (err) { + return fail(res, err, 'list') + } +} + +async function getTeam(req, res) { + try { + const team = await teams.getPublic(req.params.slug) + // A hidden Team is indistinguishable from a missing one here, deliberately: + // "absent from every public surface" includes not confirming it exists. + if (!team) return res.status(404).json({ message: 'Team not found' }) + return res.json(team) + } catch (err) { + return fail(res, err, 'get') + } +} + +async function getRoster(req, res) { + try { + const roster = await teams.rosterPublic(req.params.slug) + if (!roster) return res.status(404).json({ message: 'Team not found' }) + return res.json(roster) + } catch (err) { + return fail(res, err, 'roster') + } +} + +module.exports = { listTeams, getTeam, getRoster } diff --git a/server/src/router/v1/public/teams.router.js b/server/src/router/v1/public/teams.router.js new file mode 100644 index 0000000..3b17d25 --- /dev/null +++ b/server/src/router/v1/public/teams.router.js @@ -0,0 +1,54 @@ +// Public · Teams — the anonymous Team surface (TEAMS.md §2.11). +// +// Mounted at /api/v1/public/teams by public/index.js. No group gate: this is the +// anonymous surface, and `siteMode` is applied per route as everywhere else in +// this tier — during maintenance only an admin with a valid session sees content. +// +// Declaration order: '/' is literal and precedes the two :slug routes, and +// '/:slug/members' is deeper than '/:slug', so nothing here can shadow anything +// else. + +const express = require('express') + +const ctrl = require('./teams.controller') +const siteMode = require('../../../middleware/siteMode') + +const teamsRouter = express.Router() + +teamsRouter.get( + '/', + // #swagger.tags = ['Public · Teams'] + // #swagger.summary = 'List active, publicly visible Teams' + // #swagger.description = 'Teams hidden by reserved-name screening or by staff are absent. The response carries { stale, lastSyncAt } so a client can say how recently the projection was confirmed against the game.' + // #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Page size, max 200 (default 50).' } + // #swagger.parameters['offset'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Rows to skip (default 0).' } + /* #swagger.responses[200] = { description: 'Publicly visible Teams, with sync freshness', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicTeamList" } } } } */ + siteMode, + ctrl.listTeams, +) + +teamsRouter.get( + '/:slug', + // #swagger.tags = ['Public · Teams'] + // #swagger.summary = 'Get one Team by slug' + // #swagger.description = 'An archived Team still resolves, read-only, and names its successor when it was renamed — an old bookmark or Discord link lands somewhere that explains itself. A hidden Team returns 404, indistinguishable from one that does not exist.' + // #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' } + /* #swagger.responses[200] = { description: 'The Team', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicTeam" } } } } */ + /* #swagger.responses[404] = { description: 'No such Team, or it is hidden', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + siteMode, + ctrl.getTeam, +) + +teamsRouter.get( + '/:slug/members', + // #swagger.tags = ['Public · Teams'] + // #swagger.summary = 'Get a Team roster' + // #swagger.description = 'In-game display names only. A member key is a game-internal identifier and a user id names a site account; neither is published. `linked` answers whether a character has an account behind it without saying which.' + // #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' } + /* #swagger.responses[200] = { description: 'The roster, with sync freshness', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicTeamRoster" } } } } */ + /* #swagger.responses[404] = { description: 'No such Team, or it is hidden', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + siteMode, + ctrl.getRoster, +) + +module.exports = teamsRouter diff --git a/server/src/utils/reservedNames.js b/server/src/utils/reservedNames.js new file mode 100644 index 0000000..0d4c5b7 --- /dev/null +++ b/server/src/utils/reservedNames.js @@ -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 " 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 } diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index 0a5a789..60a8b1b 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -4309,6 +4309,727 @@ } } }, + "/api/v1/admin/teams": { + "get": { + "tags": [ + "Admin · Teams" + ], + "summary": "List Teams with sync state", + "description": "Includes hidden Teams and the module’s sync state verbatim — last attempt, last success, consecutive failures and the last error — which is what an operator debugging a stale projection needs.", + "parameters": [ + { + "name": "archived", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Set to 1 to include archived Teams." + } + ], + "responses": { + "200": { + "description": "Teams and sync state", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminTeamList" + } + } + } + }, + "400": { + "description": "Bad Request" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/teams/requests": { + "get": { + "tags": [ + "Admin · Teams" + ], + "summary": "The moderation approval queue", + "description": "Requests filed by moderators for the three actions that publish untrusted game-sourced strings. Decided rows are kept — the record that a moderator asked to publish a name and an admin refused is the part worth having.", + "parameters": [ + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "pending (default) | approved | rejected | withdrawn | all" + } + ], + "responses": { + "200": { + "description": "Moderation requests", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamRequestQueue" + } + } + } + }, + "400": { + "description": "Bad Request" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/teams/requests/{id}/decide": { + "post": { + "tags": [ + "Admin · Teams" + ], + "summary": "Approve or reject a moderation request (admin only)", + "description": "Admin only, checked live against the database rather than from a token claim. Approving applies the action; rejecting keeps the row and changes nothing. A request already decided returns 409, so two admins deciding at once cannot double-apply.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Request id." + } + ], + "responses": { + "200": { + "description": "Decided", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OkResponse" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + } + }, + "403": { + "description": "Only an admin may decide a request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "No such request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Already decided", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamDecideRequest" + } + } + } + } + } + }, + "/api/v1/admin/teams/resync": { + "post": { + "tags": [ + "Admin · Teams" + ], + "summary": "Run a reconciliation now", + "description": "Awaited, so the response carries the outcome including the provider’s own error when it refused. The four refusal gates still apply — a manual resync cannot make core act on an answer it does not trust.", + "responses": { + "200": { + "description": "The reconciliation result", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamResyncResult" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/teams/review": { + "get": { + "tags": [ + "Admin · Teams" + ], + "summary": "The reserved-name review queue", + "description": "Teams auto-hidden because their name matched a reserved term, each showing which term matched. A Team a human has already ruled on leaves the queue and is never re-hidden by a later sweep.", + "responses": { + "200": { + "description": "Auto-hidden Teams awaiting review", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamReviewQueue" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/teams/{id}": { + "get": { + "tags": [ + "Admin · Teams" + ], + "summary": "Get one Team, with its roster, grant ledger and pending requests", + "description": "The roster carries the resolved leadership and what the game actually said, so an override is visible as a decision rather than presented as fact. Departed members are included.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Team id." + } + ], + "responses": { + "200": { + "description": "The Team", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminTeam" + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "No such Team", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/teams/{id}/archive": { + "post": { + "tags": [ + "Admin · Teams" + ], + "summary": "Archive a Team (staff)", + "description": "Not gated: archiving withdraws a Team from public surfaces rather than publishing anything.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Team id." + } + ], + "responses": { + "200": { + "description": "Archived", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OkResponse" + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "No such Team", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamReasonRequest" + } + } + } + } + } + }, + "/api/v1/admin/teams/{id}/display-name": { + "post": { + "tags": [ + "Admin · Teams" + ], + "summary": "Set or clear a Team’s display name — admin applies, moderator requests", + "description": "Gated for the same reason as un-hiding: it substitutes free text into the same public surfaces. Identity is untouched — the Team’s `name` stays frozen for the life of the row, and only what is rendered changes. An empty displayName clears the override.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Team id." + } + ], + "responses": { + "200": { + "description": "Applied, or filed for approval — see `pending`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamModerationResult" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + } + }, + "404": { + "description": "No such Team", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamDisplayNameRequest" + } + } + } + } + } + }, + "/api/v1/admin/teams/{id}/grants": { + "get": { + "tags": [ + "Admin · Teams" + ], + "summary": "The full forum-grant ledger for a Team, revoked rows included", + "description": "The structured record the access resolver reads. The grant/revoke flow itself lands in the forum phase; this is the read side.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Team id." + } + ], + "responses": { + "200": { + "description": "The grant ledger", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamGrantLedger" + } + } + } + }, + "400": { + "description": "Bad Request" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/teams/{id}/hide": { + "post": { + "tags": [ + "Admin · Teams" + ], + "summary": "Hide a Team from public surfaces (staff)", + "description": "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.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Team id." + } + ], + "responses": { + "200": { + "description": "Hidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OkResponse" + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "No such Team", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamReasonRequest" + } + } + } + } + } + }, + "/api/v1/admin/teams/{id}/leader-override": { + "post": { + "tags": [ + "Admin · Teams" + ], + "summary": "Grant or deny leadership for one member (staff)", + "description": "Applied on top of the synced value at READ time; the projection is never mutated. That is what makes an override survive a resync — one written into team_members would be undone by the next reconciliation. Not gated: it publishes no game-sourced string.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Team id." + } + ], + "responses": { + "200": { + "description": "Override set", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OkResponse" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + } + }, + "404": { + "description": "No such Team", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamLeaderOverrideRequest" + } + } + } + } + } + }, + "/api/v1/admin/teams/{id}/leader-override/{memberKey}": { + "delete": { + "tags": [ + "Admin · Teams" + ], + "summary": "Clear a leadership override (staff)", + "description": "The member reverts to whatever the game says at the next read; nothing in the projection changes, because nothing in it was ever changed.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Team id." + }, + { + "name": "memberKey", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The module’s member key." + } + ], + "responses": { + "200": { + "description": "Override cleared", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OkResponse" + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "No such override", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/teams/{id}/unhide": { + "post": { + "tags": [ + "Admin · Teams" + ], + "summary": "Un-hide a Team — admin applies, moderator requests", + "description": "One of the three gated actions: it publishes a name that tripped the impersonation list. An admin applies it at once; a moderator files a pending request and nothing changes publicly until an admin approves.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Team id." + } + ], + "responses": { + "200": { + "description": "Applied, or filed for approval — see `pending`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamModerationResult" + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "No such Team", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamReasonRequest" + } + } + } + } + } + }, "/api/v1/admin/uploads": { "post": { "tags": [ @@ -9268,6 +9989,110 @@ ] } }, + "/api/v1/player/teams": { + "get": { + "tags": [ + "Player · Teams" + ], + "summary": "List the caller’s Teams, with the reason for each", + "description": "Membership and forum grants are separate authority paths, so each Team carries `reason`: membership | grant | both. A Team hidden from public surfaces is still listed here — suppression is a public-surface rule, and a member is not a member of the public.", + "responses": { + "200": { + "description": "The caller’s Teams", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlayerTeamList" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Account not active (disabled/banned)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/player/teams/{slug}/access": { + "get": { + "tags": [ + "Player · Teams" + ], + "summary": "The caller’s own resolved access on one Team", + "description": "Reports viaMembership and viaGrant separately, and keeps both when both hold: the UI presents membership as the current reason while the grant survives as audit history.", + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The Team slug." + } + ], + "responses": { + "200": { + "description": "The caller’s access", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlayerTeamAccess" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "No such Team", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, "/api/v1/public/contact": { "post": { "tags": [ @@ -9613,6 +10438,140 @@ } } }, + "/api/v1/public/teams": { + "get": { + "tags": [ + "Public · Teams" + ], + "summary": "List active, publicly visible Teams", + "description": "Teams hidden by reserved-name screening or by staff are absent. The response carries { stale, lastSyncAt } so a client can say how recently the projection was confirmed against the game.", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Page size, max 200 (default 50)." + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Rows to skip (default 0)." + } + ], + "responses": { + "200": { + "description": "Publicly visible Teams, with sync freshness", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicTeamList" + } + } + } + }, + "503": { + "description": "Service Unavailable" + } + } + } + }, + "/api/v1/public/teams/{slug}": { + "get": { + "tags": [ + "Public · Teams" + ], + "summary": "Get one Team by slug", + "description": "An archived Team still resolves, read-only, and names its successor when it was renamed — an old bookmark or Discord link lands somewhere that explains itself. A hidden Team returns 404, indistinguishable from one that does not exist.", + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The Team slug." + } + ], + "responses": { + "200": { + "description": "The Team", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicTeam" + } + } + } + }, + "404": { + "description": "No such Team, or it is hidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Service Unavailable" + } + } + } + }, + "/api/v1/public/teams/{slug}/members": { + "get": { + "tags": [ + "Public · Teams" + ], + "summary": "Get a Team roster", + "description": "In-game display names only. A member key is a game-internal identifier and a user id names a site account; neither is published. `linked` answers whether a character has an account behind it without saying which.", + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The Team slug." + } + ], + "responses": { + "200": { + "description": "The roster, with sync freshness", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicTeamRoster" + } + } + } + }, + "404": { + "description": "No such Team, or it is hidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Service Unavailable" + } + } + } + }, "/api/v1/public/version": { "get": { "tags": [ @@ -15728,6 +16687,2400 @@ } } } + }, + "OkResponse": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "ok": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + } + } + } + } + }, + "TeamSyncFreshness": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "Freshness of core's projection of the game's Teams. Carried on every public Team payload so a page can say how recently the roster was confirmed rather than presenting stale data as current. `configured` is false when no module supplies a Team provider — a deployment with no game module is not a stale one." + }, + "properties": { + "type": "object", + "properties": { + "configured": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "stale": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "description": { + "type": "string", + "example": "True past twice the reconcile interval, or when the projection has never synced at all." + }, + "example": { + "type": "boolean", + "example": false + } + } + }, + "lastSyncAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "consecutiveFailures": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 0 + } + } + } + } + } + } + }, + "PublicTeam": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "A Team as an anonymous caller sees it. `name` is what is DISPLAYED — a staff display-name override, when one is set — never the frozen identity behind it." + }, + "properties": { + "type": "object", + "properties": { + "slug": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "the-silver-hand" + } + } + }, + "name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "The Silver Hand" + } + } + }, + "abbr": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "TSH" + } + } + }, + "memberCount": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 42 + } + } + }, + "linkedCount": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "description": { + "type": "string", + "example": "Members with a linked site account." + }, + "example": { + "type": "number", + "example": 11 + } + } + }, + "onlineCount": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 3 + } + } + }, + "meta": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "additionalProperties": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "Module-supplied and opaque to core." + } + } + }, + "status": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "active", + "archived" + ], + "items": { + "type": "string" + } + } + } + }, + "createdAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + } + } + }, + "rosterSyncedAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "archivedAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "archivedReason": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "renamed" + } + } + }, + "successor": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "Where an archived Team continued after a rename, so an old link explains itself instead of 404ing." + }, + "properties": { + "type": "object", + "properties": { + "slug": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + }, + "name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + } + } + } + } + } + } + } + } + }, + "PublicTeamList": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "allOf": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TeamSyncFreshness" + } + }, + "properties": { + "type": "object", + "properties": { + "teams": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "$ref": "#/components/schemas/PublicTeam" + } + } + }, + "total": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 12 + } + } + } + } + } + } + }, + "PublicTeamMember": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "A roster row as an anonymous caller sees it. The member key is a game-internal identifier and the user id names a site account; neither is published. `linked` answers whether a character has an account behind it without saying which." + }, + "properties": { + "type": "object", + "properties": { + "displayName": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "Aldric" + } + } + }, + "rankLabel": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "Module vocabulary, opaque to core." + }, + "example": { + "type": "string", + "example": "Warlord" + } + } + }, + "isLeader": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "online": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": false + } + } + }, + "linked": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + } + } + } + } + }, + "PublicTeamRoster": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "allOf": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TeamSyncFreshness" + } + }, + "properties": { + "type": "object", + "properties": { + "members": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "$ref": "#/components/schemas/PublicTeamMember" + } + } + }, + "rosterSyncedAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + } + } + } + } + }, + "PlayerTeamList": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "allOf": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TeamSyncFreshness" + } + }, + "properties": { + "type": "object", + "properties": { + "teams": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "allOf": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PublicTeam" + } + }, + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "reason": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "membership", + "grant", + "both" + ], + "items": { + "type": "string" + } + }, + "description": { + "type": "string", + "example": "Which authority path lists this Team for the caller. `both` is a real state and is kept: membership is the current reason while the grant survives as audit history." + } + } + }, + "isLeader": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + } + } + } + } + } + } + } + } + } + } + } + } + }, + "PlayerTeamAccess": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "slug": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + }, + "allowed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + } + } + }, + "viaMembership": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + } + } + }, + "viaGrant": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "description": { + "type": "string", + "example": "Reported even when membership also holds." + } + } + }, + "isLeader": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "description": { + "type": "string", + "example": "The synced value with any staff override applied." + } + } + } + } + } + } + }, + "AdminTeam": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "The full staff view, including what a staff decision overrode." + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "moduleId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "uo" + } + } + }, + "externalId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "description": { + "type": "string", + "example": "The module's own stable id, opaque to core." + } + } + }, + "slug": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + }, + "name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "description": { + "type": "string", + "example": "The frozen identity. Immutable for the life of the row." + } + } + }, + "displayName": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "description": { + "type": "string", + "example": "What is rendered — the override when set, otherwise `name`." + } + } + }, + "displayNameOverride": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "abbr": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "status": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "active", + "archived" + ], + "items": { + "type": "string" + } + } + } + }, + "hidden": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + } + } + }, + "hiddenReason": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "enum": { + "type": "array", + "example": [ + "reserved_name", + "staff", + null + ], + "items": {} + } + } + }, + "hiddenTerm": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "Which reserved term matched." + }, + "example": { + "type": "string", + "example": "admin" + } + } + }, + "nameReviewedAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "Set once a human has ruled on the name; a later sweep never re-hides it." + } + } + }, + "memberCount": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "linkedCount": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "onlineCount": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "rosterSyncedAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "membersEmptySince": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "The per-Team empty-roster quarantine." + } + } + }, + "succeededBy": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "createdAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + } + } + }, + "archivedAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "archivedReason": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "meta": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "additionalProperties": { + "type": "boolean", + "example": true + } + } + }, + "members": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "$ref": "#/components/schemas/AdminTeamMember" + } + } + }, + "grants": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "$ref": "#/components/schemas/TeamGrant" + } + } + }, + "pendingRequests": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "$ref": "#/components/schemas/TeamModerationRequest" + } + } + } + } + } + } + }, + "AdminTeamMember": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "memberKey": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "0x40012ab3" + } + } + }, + "displayName": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "userId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "Resolved by the module; null means unlinked." + } + } + }, + "rankLabel": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "isLeader": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "description": { + "type": "string", + "example": "The resolved answer — synced value with any override applied." + } + } + }, + "isLeaderSynced": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "description": { + "type": "string", + "example": "What the game actually said, so an override reads as a decision rather than as fact." + } + } + }, + "leaderOverride": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "properties": { + "type": "object", + "properties": { + "effect": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "grant", + "deny" + ], + "items": { + "type": "string" + } + } + } + }, + "reason": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "by": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "at": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + } + } + } + } + } + } + }, + "online": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + } + } + }, + "status": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "active", + "departed" + ], + "items": { + "type": "string" + } + } + } + }, + "firstSeenAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + } + } + }, + "lastSeenAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + } + } + }, + "departedAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + } + } + } + } + }, + "AdminTeamList": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "allOf": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TeamSyncFreshness" + } + }, + "properties": { + "type": "object", + "properties": { + "teams": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "$ref": "#/components/schemas/AdminTeam" + } + } + }, + "syncState": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "The module's sync row verbatim, including the last error — what an operator debugging a stale projection needs." + }, + "properties": { + "type": "object", + "properties": { + "moduleId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + }, + "lastAttemptAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "lastSuccessAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "consecutiveFailures": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "lastError": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "pendingEmptySince": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + } + } + } + } + } + } + } + } + }, + "TeamGrant": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "One row of the append-only forum grant/revoke ledger. The username snapshots keep the record readable after an account is deleted — the ids go SET NULL, the audit trail does not." + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "team_id": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "user_id": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "username": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "granted_by": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "granted_username": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "granted_at": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + } + } + }, + "reason": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "revoked_by": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "revoked_username": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "revoked_at": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "revoke_reason": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + } + } + } + } + }, + "TeamGrantLedger": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "grants": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "$ref": "#/components/schemas/TeamGrant" + } + } + } + } + } + } + }, + "TeamModerationRequest": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "team_id": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "team_name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + }, + "team_slug": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + }, + "action": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "unhide", + "display_name_override", + "clear_display_name_override" + ], + "items": { + "type": "string" + } + } + } + }, + "payload": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "additionalProperties": { + "type": "boolean", + "example": true + } + } + }, + "reason": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "requested_by": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "requested_username": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "requested_at": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + } + } + }, + "status": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "pending", + "approved", + "rejected", + "withdrawn" + ], + "items": { + "type": "string" + } + } + } + }, + "decided_by": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "decided_username": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "decided_at": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "decision_note": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + } + } + } + } + }, + "TeamRequestQueue": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "requests": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "$ref": "#/components/schemas/TeamModerationRequest" + } + } + } + } + } + } + }, + "TeamReviewQueue": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "Teams auto-hidden by reserved-name screening and not yet ruled on by a human." + }, + "properties": { + "type": "object", + "properties": { + "teams": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "Admin" + } + } + }, + "slug": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + }, + "hidden_term": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "admin" + } + } + }, + "display_name_override": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "member_count": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "created_at": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + } + } + } + } + } + } + } + } + } + } + } + } + }, + "TeamModerationResult": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "The outcome of a gated action. `pending: true` means a moderator filed a request and nothing changed publicly; an admin's call applies at once and reports false." + }, + "properties": { + "type": "object", + "properties": { + "ok": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + } + } + }, + "pending": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": false + } + } + }, + "requestId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + } + } + } + } + }, + "TeamResyncResult": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "A reconciliation outcome. `ok: false` carries the provider's own reason and means nothing was written. `quarantined` means an authoritative-but-empty answer was held back for confirmation rather than applied." + }, + "properties": { + "type": "object", + "properties": { + "ok": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + } + } + }, + "reason": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "quarantined": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "created": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "renamed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "archived": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "rosters": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "Rosters actually applied; a refused one is left untouched and not counted." + } + } + }, + "rehidden": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + } + } + } + } + }, + "TeamReasonRequest": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "reason": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "maxLength": { + "type": "number", + "example": 255 + }, + "example": { + "type": "string", + "example": "impersonates staff" + } + } + } + } + } + } + }, + "TeamDisplayNameRequest": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "displayName": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "maxLength": { + "type": "number", + "example": 160 + }, + "description": { + "type": "string", + "example": "Empty or null clears the override." + }, + "example": { + "type": "string", + "example": "The Old Guard" + } + } + }, + "reason": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "maxLength": { + "type": "number", + "example": 255 + } + } + } + } + } + } + }, + "TeamLeaderOverrideRequest": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "required": { + "type": "array", + "example": [ + "memberKey", + "effect" + ], + "items": { + "type": "string" + } + }, + "properties": { + "type": "object", + "properties": { + "memberKey": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "maxLength": { + "type": "number", + "example": 191 + }, + "example": { + "type": "string", + "example": "0x40012ab3" + } + } + }, + "effect": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "grant", + "deny" + ], + "items": { + "type": "string" + } + } + } + }, + "reason": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "maxLength": { + "type": "number", + "example": 255 + } + } + } + } + } + } + }, + "TeamDecideRequest": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "required": { + "type": "array", + "example": [ + "status" + ], + "items": { + "type": "string" + } + }, + "properties": { + "type": "object", + "properties": { + "status": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "approved", + "rejected" + ], + "items": { + "type": "string" + } + } + } + }, + "note": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "maxLength": { + "type": "number", + "example": 255 + } + } + } + } + } + } } } } diff --git a/server/swagger/swagger.js b/server/swagger/swagger.js index 91197a9..03ebf40 100644 --- a/server/swagger/swagger.js +++ b/server/swagger/swagger.js @@ -927,6 +927,310 @@ const doc = { removed: { type: 'boolean', description: 'Whether the IP had an entry that was cleared.', example: true }, }, }, + + // ── Teams (docs/website/TEAMS.md) ──────────────────────────────────── + OkResponse: { + type: 'object', + properties: { ok: { type: 'boolean', example: true } }, + }, + TeamSyncFreshness: { + type: 'object', + description: + 'Freshness of core\'s projection of the game\'s Teams. Carried on every public Team payload so a page can say how recently the roster was confirmed rather than presenting stale data as current. `configured` is false when no module supplies a Team provider — a deployment with no game module is not a stale one.', + properties: { + configured: { type: 'boolean', example: true }, + stale: { + type: 'boolean', + description: 'True past twice the reconcile interval, or when the projection has never synced at all.', + example: false, + }, + lastSyncAt: { type: 'string', format: 'date-time', nullable: true }, + consecutiveFailures: { type: 'integer', example: 0 }, + }, + }, + PublicTeam: { + type: 'object', + description: + 'A Team as an anonymous caller sees it. `name` is what is DISPLAYED — a staff display-name override, when one is set — never the frozen identity behind it.', + properties: { + slug: { type: 'string', example: 'the-silver-hand' }, + name: { type: 'string', example: 'The Silver Hand' }, + abbr: { type: 'string', nullable: true, example: 'TSH' }, + memberCount: { type: 'integer', example: 42 }, + linkedCount: { type: 'integer', description: 'Members with a linked site account.', example: 11 }, + onlineCount: { type: 'integer', example: 3 }, + meta: { type: 'object', nullable: true, additionalProperties: true, description: 'Module-supplied and opaque to core.' }, + status: { type: 'string', enum: ['active', 'archived'] }, + createdAt: { type: 'string', format: 'date-time' }, + rosterSyncedAt: { type: 'string', format: 'date-time', nullable: true }, + archivedAt: { type: 'string', format: 'date-time', nullable: true }, + archivedReason: { type: 'string', nullable: true, example: 'renamed' }, + successor: { + type: 'object', + nullable: true, + description: 'Where an archived Team continued after a rename, so an old link explains itself instead of 404ing.', + properties: { slug: { type: 'string' }, name: { type: 'string' } }, + }, + }, + }, + PublicTeamList: { + type: 'object', + allOf: [{ $ref: '#/components/schemas/TeamSyncFreshness' }], + properties: { + teams: { type: 'array', items: { $ref: '#/components/schemas/PublicTeam' } }, + total: { type: 'integer', example: 12 }, + }, + }, + PublicTeamMember: { + type: 'object', + description: + 'A roster row as an anonymous caller sees it. The member key is a game-internal identifier and the user id names a site account; neither is published. `linked` answers whether a character has an account behind it without saying which.', + properties: { + displayName: { type: 'string', nullable: true, example: 'Aldric' }, + rankLabel: { type: 'string', nullable: true, description: 'Module vocabulary, opaque to core.', example: 'Warlord' }, + isLeader: { type: 'boolean', example: true }, + online: { type: 'boolean', example: false }, + linked: { type: 'boolean', example: true }, + }, + }, + PublicTeamRoster: { + type: 'object', + allOf: [{ $ref: '#/components/schemas/TeamSyncFreshness' }], + properties: { + members: { type: 'array', items: { $ref: '#/components/schemas/PublicTeamMember' } }, + rosterSyncedAt: { type: 'string', format: 'date-time', nullable: true }, + }, + }, + PlayerTeamList: { + type: 'object', + allOf: [{ $ref: '#/components/schemas/TeamSyncFreshness' }], + properties: { + teams: { + type: 'array', + items: { + allOf: [{ $ref: '#/components/schemas/PublicTeam' }], + type: 'object', + properties: { + reason: { + type: 'string', + enum: ['membership', 'grant', 'both'], + description: 'Which authority path lists this Team for the caller. `both` is a real state and is kept: membership is the current reason while the grant survives as audit history.', + }, + isLeader: { type: 'boolean' }, + }, + }, + }, + }, + }, + PlayerTeamAccess: { + type: 'object', + properties: { + slug: { type: 'string' }, + allowed: { type: 'boolean' }, + viaMembership: { type: 'boolean' }, + viaGrant: { type: 'boolean', description: 'Reported even when membership also holds.' }, + isLeader: { type: 'boolean', description: 'The synced value with any staff override applied.' }, + }, + }, + AdminTeam: { + type: 'object', + description: 'The full staff view, including what a staff decision overrode.', + properties: { + id: { type: 'integer' }, + moduleId: { type: 'string', example: 'uo' }, + externalId: { type: 'string', description: 'The module\'s own stable id, opaque to core.' }, + slug: { type: 'string' }, + name: { type: 'string', description: 'The frozen identity. Immutable for the life of the row.' }, + displayName: { type: 'string', description: 'What is rendered — the override when set, otherwise `name`.' }, + displayNameOverride: { type: 'string', nullable: true }, + abbr: { type: 'string', nullable: true }, + status: { type: 'string', enum: ['active', 'archived'] }, + hidden: { type: 'boolean' }, + hiddenReason: { type: 'string', nullable: true, enum: ['reserved_name', 'staff', null] }, + hiddenTerm: { type: 'string', nullable: true, description: 'Which reserved term matched.', example: 'admin' }, + nameReviewedAt: { type: 'string', format: 'date-time', nullable: true, description: 'Set once a human has ruled on the name; a later sweep never re-hides it.' }, + memberCount: { type: 'integer' }, + linkedCount: { type: 'integer' }, + onlineCount: { type: 'integer' }, + rosterSyncedAt: { type: 'string', format: 'date-time', nullable: true }, + membersEmptySince: { type: 'string', format: 'date-time', nullable: true, description: 'The per-Team empty-roster quarantine.' }, + succeededBy: { type: 'integer', nullable: true }, + createdAt: { type: 'string', format: 'date-time' }, + archivedAt: { type: 'string', format: 'date-time', nullable: true }, + archivedReason: { type: 'string', nullable: true }, + meta: { type: 'object', nullable: true, additionalProperties: true }, + members: { type: 'array', items: { $ref: '#/components/schemas/AdminTeamMember' } }, + grants: { type: 'array', items: { $ref: '#/components/schemas/TeamGrant' } }, + pendingRequests: { type: 'array', items: { $ref: '#/components/schemas/TeamModerationRequest' } }, + }, + }, + AdminTeamMember: { + type: 'object', + properties: { + memberKey: { type: 'string', example: '0x40012ab3' }, + displayName: { type: 'string', nullable: true }, + userId: { type: 'integer', nullable: true, description: 'Resolved by the module; null means unlinked.' }, + rankLabel: { type: 'string', nullable: true }, + isLeader: { type: 'boolean', description: 'The resolved answer — synced value with any override applied.' }, + isLeaderSynced: { type: 'boolean', description: 'What the game actually said, so an override reads as a decision rather than as fact.' }, + leaderOverride: { + type: 'object', + nullable: true, + properties: { + effect: { type: 'string', enum: ['grant', 'deny'] }, + reason: { type: 'string', nullable: true }, + by: { type: 'string', nullable: true }, + at: { type: 'string', format: 'date-time' }, + }, + }, + online: { type: 'boolean' }, + status: { type: 'string', enum: ['active', 'departed'] }, + firstSeenAt: { type: 'string', format: 'date-time' }, + lastSeenAt: { type: 'string', format: 'date-time' }, + departedAt: { type: 'string', format: 'date-time', nullable: true }, + }, + }, + AdminTeamList: { + type: 'object', + allOf: [{ $ref: '#/components/schemas/TeamSyncFreshness' }], + properties: { + teams: { type: 'array', items: { $ref: '#/components/schemas/AdminTeam' } }, + syncState: { + type: 'object', + nullable: true, + description: 'The module\'s sync row verbatim, including the last error — what an operator debugging a stale projection needs.', + properties: { + moduleId: { type: 'string' }, + lastAttemptAt: { type: 'string', format: 'date-time', nullable: true }, + lastSuccessAt: { type: 'string', format: 'date-time', nullable: true }, + consecutiveFailures: { type: 'integer' }, + lastError: { type: 'string', nullable: true }, + pendingEmptySince: { type: 'string', format: 'date-time', nullable: true }, + }, + }, + }, + }, + TeamGrant: { + type: 'object', + description: + 'One row of the append-only forum grant/revoke ledger. The username snapshots keep the record readable after an account is deleted — the ids go SET NULL, the audit trail does not.', + properties: { + id: { type: 'integer' }, + team_id: { type: 'integer' }, + user_id: { type: 'integer', nullable: true }, + username: { type: 'string', nullable: true }, + granted_by: { type: 'integer', nullable: true }, + granted_username: { type: 'string', nullable: true }, + granted_at: { type: 'string', format: 'date-time' }, + reason: { type: 'string', nullable: true }, + revoked_by: { type: 'integer', nullable: true }, + revoked_username: { type: 'string', nullable: true }, + revoked_at: { type: 'string', format: 'date-time', nullable: true }, + revoke_reason: { type: 'string', nullable: true }, + }, + }, + TeamGrantLedger: { + type: 'object', + properties: { grants: { type: 'array', items: { $ref: '#/components/schemas/TeamGrant' } } }, + }, + TeamModerationRequest: { + type: 'object', + properties: { + id: { type: 'integer' }, + team_id: { type: 'integer' }, + team_name: { type: 'string' }, + team_slug: { type: 'string' }, + action: { type: 'string', enum: ['unhide', 'display_name_override', 'clear_display_name_override'] }, + payload: { type: 'object', nullable: true, additionalProperties: true }, + reason: { type: 'string', nullable: true }, + requested_by: { type: 'integer', nullable: true }, + requested_username: { type: 'string', nullable: true }, + requested_at: { type: 'string', format: 'date-time' }, + status: { type: 'string', enum: ['pending', 'approved', 'rejected', 'withdrawn'] }, + decided_by: { type: 'integer', nullable: true }, + decided_username: { type: 'string', nullable: true }, + decided_at: { type: 'string', format: 'date-time', nullable: true }, + decision_note: { type: 'string', nullable: true }, + }, + }, + TeamRequestQueue: { + type: 'object', + properties: { requests: { type: 'array', items: { $ref: '#/components/schemas/TeamModerationRequest' } } }, + }, + TeamReviewQueue: { + type: 'object', + description: 'Teams auto-hidden by reserved-name screening and not yet ruled on by a human.', + properties: { + teams: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'integer' }, + name: { type: 'string', example: 'Admin' }, + slug: { type: 'string' }, + hidden_term: { type: 'string', example: 'admin' }, + display_name_override: { type: 'string', nullable: true }, + member_count: { type: 'integer' }, + created_at: { type: 'string', format: 'date-time' }, + }, + }, + }, + }, + }, + TeamModerationResult: { + type: 'object', + description: + 'The outcome of a gated action. `pending: true` means a moderator filed a request and nothing changed publicly; an admin\'s call applies at once and reports false.', + properties: { + ok: { type: 'boolean' }, + pending: { type: 'boolean', example: false }, + requestId: { type: 'integer', nullable: true }, + }, + }, + TeamResyncResult: { + type: 'object', + description: + 'A reconciliation outcome. `ok: false` carries the provider\'s own reason and means nothing was written. `quarantined` means an authoritative-but-empty answer was held back for confirmation rather than applied.', + properties: { + ok: { type: 'boolean' }, + reason: { type: 'string', nullable: true }, + quarantined: { type: 'boolean', nullable: true }, + created: { type: 'integer', nullable: true }, + renamed: { type: 'integer', nullable: true }, + archived: { type: 'integer', nullable: true }, + rosters: { type: 'integer', nullable: true, description: 'Rosters actually applied; a refused one is left untouched and not counted.' }, + rehidden: { type: 'integer', nullable: true }, + }, + }, + TeamReasonRequest: { + type: 'object', + properties: { reason: { type: 'string', maxLength: 255, example: 'impersonates staff' } }, + }, + TeamDisplayNameRequest: { + type: 'object', + properties: { + displayName: { type: 'string', nullable: true, maxLength: 160, description: 'Empty or null clears the override.', example: 'The Old Guard' }, + reason: { type: 'string', maxLength: 255 }, + }, + }, + TeamLeaderOverrideRequest: { + type: 'object', + required: ['memberKey', 'effect'], + properties: { + memberKey: { type: 'string', maxLength: 191, example: '0x40012ab3' }, + effect: { type: 'string', enum: ['grant', 'deny'] }, + reason: { type: 'string', maxLength: 255 }, + }, + }, + TeamDecideRequest: { + type: 'object', + required: ['status'], + properties: { + status: { type: 'string', enum: ['approved', 'rejected'] }, + note: { type: 'string', maxLength: 255 }, + }, + }, }, }, } diff --git a/server/test/moduleLoader.test.js b/server/test/moduleLoader.test.js index c0ebe3a..cef7339 100644 --- a/server/test/moduleLoader.test.js +++ b/server/test/moduleLoader.test.js @@ -507,9 +507,13 @@ test('ctx exposes exactly the documented surface, and is frozen', () => { // extraction needed it and none could be vendored: an admin action a module // performs belongs in core's one audit log, the extension slot needs the user // its prefix names, and §2.7 forbids a module reading core's APP_BASE_URL. + // API 1.6.0 added `teams` — push, to the pull the team provider answers + // (TEAMS.md §2.3). Read-only by omission: a module answers questions about + // Teams and never asks them, so there is no getter here to add later by + // accident. assert.deepEqual(probe.keys, [ 'activity', 'auth', 'db', 'express', 'log', 'middleware', 'moduleId', 'paths', - 'posts', 'push', 'secretBox', 'settings', 'site', 'uploads', 'users', 'validator', + 'posts', 'push', 'secretBox', 'settings', 'site', 'teams', 'uploads', 'users', 'validator', ]) // is core's limiter FACTORY, not a limiter: a module states its own // window and cap and takes the plumbing, so there is one express-rate-limit in diff --git a/server/test/reservedNames.test.js b/server/test/reservedNames.test.js new file mode 100644 index 0000000..91c4477 --- /dev/null +++ b/server/test/reservedNames.test.js @@ -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) +}) diff --git a/server/test/teamAccess.test.js b/server/test/teamAccess.test.js new file mode 100644 index 0000000..3d70ce2 --- /dev/null +++ b/server/test/teamAccess.test.js @@ -0,0 +1,209 @@ +// The four authority paths, and the rule that they stay four +// (docs/website/TEAMS.md §2.5). +// +// Two of these tests are named for invariants rather than for behaviour, because +// what they protect is a structural property that a perfectly reasonable-looking +// refactor destroys: "has forum access" is never read as "is a member", and a +// grant never writes the membership projection. Both are one `||` away from being +// wrong, and neither failure is visible on any screen — the first shows up as a +// stranger on a public roster, the second as a Discord role handed to an account +// nobody can tie to a real player. +const { test, beforeEach, afterEach } = require('node:test') +const assert = require('node:assert/strict') + +const accessDb = require('../src/model/teams/teamAccess.db') +const teamsDb = require('../src/model/teams/teams.db') +const identities = require('../src/model/userIdentities/userIdentities.model') +const access = require('../src/model/teams/teamAccess.model') + +const saved = [] +function patch(mod, name, fn) { + saved.push([mod, name, mod[name]]) + mod[name] = fn +} + +// Every read the four paths can make, stubbed to "nothing there". Each test then +// states only the fact it is about, which is what makes a crossed path obvious: +// a resolver reading a table it should not would come back empty and the +// assertion would say so. +function stubAll() { + patch(accessDb, 'activeGrant', async () => undefined) + patch(accessDb, 'overrideFor', async () => undefined) + patch(accessDb, 'overridesForTeam', async () => []) + patch(teamsDb, 'activeByUser', async () => undefined) + patch(teamsDb, 'membersByTeam', async () => []) + patch(identities, 'listForUser', async () => []) +} + +const memberRow = (extra = {}) => ({ + team_id: 1, member_key: '0x1', user_id: 7, is_leader: 0, status: 'active', display_name: 'Aldric', ...extra, +}) +const grantRow = (extra = {}) => ({ id: 1, team_id: 1, user_id: 7, granted_by: 2, revoked_at: null, ...extra }) + +beforeEach(stubAll) +afterEach(() => { + while (saved.length) { + const [mod, name, fn] = saved.pop() + mod[name] = fn + } +}) + +// ── Path 3: forum access is membership OR a grant ────────────────────────── + +test('a member has forum access via membership', async () => { + patch(teamsDb, 'activeByUser', async () => memberRow()) + const result = await access.forumAccess(1, 7) + assert.deepEqual(result, { allowed: true, viaMembership: true, viaGrant: false, isLeader: false }) +}) + +test('a granted non-member has forum access via the grant', async () => { + patch(accessDb, 'activeGrant', async () => grantRow()) + const result = await access.forumAccess(1, 7) + assert.deepEqual(result, { allowed: true, viaMembership: false, viaGrant: true, isLeader: false }) +}) + +test('both reasons are reported when both hold', async () => { + // Not collapsed into one boolean: both facts are true, membership is what the + // UI shows as the current reason, and the grant stays as the record of who let + // this person in before they were a member. + patch(accessDb, 'activeGrant', async () => grantRow()) + patch(teamsDb, 'activeByUser', async () => memberRow()) + const result = await access.forumAccess(1, 7) + assert.equal(result.viaMembership, true) + assert.equal(result.viaGrant, true) +}) + +test('a revoked grant and no membership is no access', async () => { + // activeGrant returns nothing for a revoked row — the resolver never sees one. + const result = await access.forumAccess(1, 7) + assert.equal(result.allowed, false) +}) + +test('an anonymous caller is refused without touching a table', async () => { + let reads = 0 + patch(accessDb, 'activeGrant', async () => { reads += 1 }) + patch(teamsDb, 'activeByUser', async () => { reads += 1 }) + const result = await access.forumAccess(1, null) + assert.equal(result.allowed, false) + assert.equal(reads, 0) +}) + +// ── Invariant 3: non-contamination ───────────────────────────────────────── + +test('INVARIANT — a grant never writes the membership projection', async () => { + // The grant path reads its own table and nothing else. Asserted by making every + // membership WRITE explode: if resolving a grant ever wrote a member row, this + // is where it would surface. + for (const name of ['upsertMember', 'markDeparted', 'setLeaders', 'setMemberLeader']) { + patch(teamsDb, name, async () => { throw new Error(`forumAccess wrote team_members via ${name}`) }) + } + patch(accessDb, 'activeGrant', async () => grantRow()) + const result = await access.forumAccess(1, 7) + assert.equal(result.allowed, true) + assert.equal(result.viaMembership, false, 'a grant is not a membership, in either direction') +}) + +test('INVARIANT — a granted, unlinked user is not on the roster', async () => { + // The roster is path 1's table alone. A granted user with no membership row + // appears nowhere in it, which is what keeps them out of every membership count. + patch(accessDb, 'activeGrant', async () => grantRow({ user_id: 99 })) + patch(teamsDb, 'membersByTeam', async () => [memberRow()]) + + const roster = await access.rosterWithOverrides(1) + assert.equal(roster.length, 1) + assert.equal(roster.every((m) => m.user_id !== 99), true, 'a forum guest is not a member') +}) + +// ── Path 4: external access is blind to path 3 ───────────────────────────── + +test('INVARIANT — a forum grant does not make an account externally eligible', async () => { + // The named test from §2.5. An integration cannot verify that an unlinked, + // forum-granted account corresponds to a real game member, so it must not hand + // that account a privilege on a platform where impersonation has consequences. + patch(accessDb, 'activeGrant', async () => grantRow()) + patch(identities, 'listForUser', async () => [{ provider: 'discord', subject: 'd1' }]) + + assert.equal(await access.externalEligible(1, 7, 'discord'), false) +}) + +test('a linked member with a linked Discord identity is eligible', async () => { + patch(teamsDb, 'activeByUser', async () => memberRow({ user_id: 7 })) + patch(identities, 'listForUser', async () => [{ provider: 'discord', subject: 'd1' }]) + assert.equal(await access.externalEligible(1, 7, 'discord'), true) +}) + +test('a member with no Discord identity is not eligible — hop 3 of the chain', async () => { + patch(teamsDb, 'activeByUser', async () => memberRow()) + patch(identities, 'listForUser', async () => [{ provider: 'google', subject: 'g1' }]) + assert.equal(await access.externalEligible(1, 7, 'discord'), false) +}) + +test('eligibility is per platform, not "linked to anything"', async () => { + patch(teamsDb, 'activeByUser', async () => memberRow()) + patch(identities, 'listForUser', async () => [{ provider: 'discord', subject: 'd1' }]) + assert.equal(await access.externalEligible(1, 7, 'matrix'), false) +}) + +test('a non-member is never eligible', async () => { + patch(identities, 'listForUser', async () => [{ provider: 'discord', subject: 'd1' }]) + assert.equal(await access.externalEligible(1, 7, 'discord'), false) +}) + +// ── Path 2: leadership, and the staff override on top ────────────────────── + +test('leadership follows the synced value when no override exists', async () => { + patch(teamsDb, 'activeByUser', async () => memberRow({ is_leader: 1 })) + assert.equal(await access.isLeaderByUser(1, 7), true) +}) + +test('a deny override outranks a synced leader', async () => { + patch(teamsDb, 'activeByUser', async () => memberRow({ is_leader: 1 })) + patch(accessDb, 'overrideFor', async () => ({ member_key: '0x1', effect: 'deny' })) + assert.equal(await access.isLeaderByUser(1, 7), false) +}) + +test('a grant override promotes someone the game does not call a leader', async () => { + patch(teamsDb, 'activeByUser', async () => memberRow({ is_leader: 0 })) + patch(accessDb, 'overrideFor', async () => ({ member_key: '0x1', effect: 'grant' })) + assert.equal(await access.isLeaderByUser(1, 7), true) +}) + +test('an override survives a resync, because it is never written into the projection', async () => { + // The projection keeps saying what the game says; the override keeps saying what + // staff decided. Applied at READ time, so a sync fifteen minutes later cannot + // undo it — which is the entire point of §2.5.1. + patch(accessDb, 'overridesForTeam', async () => [ + { member_key: '0x1', effect: 'deny', reason: 'harassment', actor_username: 'mod1', created_at: 'then' }, + ]) + patch(teamsDb, 'membersByTeam', async () => [memberRow({ is_leader: 1 })]) + + const roster = await access.rosterWithOverrides(1) + assert.equal(roster[0].is_leader, false, 'the resolved answer is the override') + assert.equal(roster[0].is_leader_synced, true, 'what the game says is still visible') + assert.equal(roster[0].leader_override.reason, 'harassment') + assert.equal(roster[0].leader_override.by, 'mod1') +}) + +test('a member with no override carries no override field', async () => { + patch(teamsDb, 'membersByTeam', async () => [memberRow({ is_leader: 1 })]) + const roster = await access.rosterWithOverrides(1) + assert.equal(roster[0].leader_override, null) + assert.equal(roster[0].is_leader, true) +}) + +test('leadership resolves through forumAccess too, override included', async () => { + patch(teamsDb, 'activeByUser', async () => memberRow({ is_leader: 0 })) + patch(accessDb, 'overrideFor', async () => ({ member_key: '0x1', effect: 'grant' })) + const result = await access.forumAccess(1, 7) + assert.equal(result.isLeader, true) +}) + +test('a granted non-member is never a leader', async () => { + // isLeader is path 2, which is a property of a MEMBER row. Someone with only a + // forum grant has no member row, so there is nothing to promote. + patch(accessDb, 'activeGrant', async () => grantRow()) + patch(accessDb, 'overrideFor', async () => ({ member_key: '0x1', effect: 'grant' })) + const result = await access.forumAccess(1, 7) + assert.equal(result.allowed, true) + assert.equal(result.isLeader, false) +}) diff --git a/server/test/teamModeration.test.js b/server/test/teamModeration.test.js new file mode 100644 index 0000000..77455b2 --- /dev/null +++ b/server/test/teamModeration.test.js @@ -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/) +}) diff --git a/server/test/teamProvider.test.js b/server/test/teamProvider.test.js new file mode 100644 index 0000000..75bdd5b --- /dev/null +++ b/server/test/teamProvider.test.js @@ -0,0 +1,289 @@ +// The Team provider registration and the guarded call path +// (docs/website/TEAMS.md §2.3). +// +// Almost every test here is invariant 1 asked a different way: **module +// unavailability is staleness, never emptiness.** The value of this file is that +// it enumerates the shapes a broken provider can produce and asserts that none of +// them arrives at the reconciler looking like authoritative data. +const { test, beforeEach, afterEach } = require('node:test') +const assert = require('node:assert/strict') + +const registries = require('../src/modules/registries') +const teamProvider = require('../src/model/teams/teamProvider') + +// Register a provider the way a module does: stage, then commit. +function register(owner, provider) { + const api = registries.stage(owner) + api.registerTeamProvider(provider) + registries.apply(api.staged) +} + +const ok = () => ({ + getTeams: async () => ({ ok: true, teams: [{ externalId: 'g1', name: 'The Silver Hand' }] }), + getTeamMembers: async () => ({ ok: true, members: [{ memberKey: '0x1', displayName: 'Aldric' }] }), + getTeamLeaders: async () => ({ ok: true, leaders: ['0x1'] }), +}) + +beforeEach(() => registries._reset()) +afterEach(() => registries._reset()) + +// ── Registration ─────────────────────────────────────────────────────────── + +test('a provider is readable only after apply(), not at stage time', () => { + const api = registries.stage('uo') + api.registerTeamProvider(ok()) + assert.equal(registries.hasTeamProvider(), false, 'staging must not publish') + + registries.apply(api.staged) + assert.equal(registries.hasTeamProvider(), true) + assert.equal(registries.registeredTeamProvider().owner, 'uo') +}) + +test('all three methods are required', () => { + const api = registries.stage('uo') + for (const missing of ['getTeams', 'getTeamMembers', 'getTeamLeaders']) { + const provider = ok() + delete provider[missing] + assert.throws(() => api.registerTeamProvider(provider), new RegExp(`${missing}\\(\\) is missing`)) + } + // A non-function is the same failure, and is the likelier typo. + assert.throws(() => api.registerTeamProvider({ ...ok(), getTeams: 'yes' }), /getTeams\(\) is missing or not a function/) +}) + +test('a second provider is a collision naming the module that holds it', () => { + register('uo', ok()) + const second = registries.stage('other') + second.registerTeamProvider(ok()) + assert.throws(() => registries.apply(second.staged), /already registered by "uo"/) + // The first registration is untouched by the rejected second. + assert.equal(registries.registeredTeamProvider().owner, 'uo') +}) + +test('one module registering twice in one batch is rejected', () => { + const api = registries.stage('uo') + api.registerTeamProvider(ok()) + api.registerTeamProvider(ok()) + assert.throws(() => registries.apply(api.staged), /more than one team provider/) + assert.equal(registries.hasTeamProvider(), false, 'the whole batch is refused') +}) + +test('a rejected batch leaves no provider behind, even when its other claims are fine', () => { + const api = registries.stage('uo') + api.registerTeamProvider(ok()) + api.registerNotificationStreams([{ id: 'uo.thing', label: 'Thing' }]) + api.registerNotificationStreams([{ id: 'uo.thing', label: 'Thing' }]) // duplicate + assert.throws(() => registries.apply(api.staged)) + assert.equal(registries.hasTeamProvider(), false, 'validate-then-commit covers the provider too') +}) + +// ── The call path: every failure shape becomes { ok: false } ─────────────── + +test('no registered provider is a refusal, not an empty answer', async () => { + const answer = await teamProvider.getTeams() + assert.equal(answer.ok, false) + assert.equal(answer.teams, undefined, 'a refusal never carries a teams array') + assert.equal(teamProvider.providerModuleId(), null) +}) + +test('a provider that throws is a refusal', async () => { + register('uo', { ...ok(), getTeams: async () => { throw new Error('sidecar unreachable') } }) + const answer = await teamProvider.getTeams() + assert.equal(answer.ok, false) + assert.match(answer.reason, /sidecar unreachable/) + assert.equal(answer.teams, undefined) +}) + +test('a provider that throws SYNCHRONOUSLY is a refusal too', async () => { + register('uo', { ...ok(), getTeams: () => { throw new Error('boom') } }) + const answer = await teamProvider.getTeams() + assert.equal(answer.ok, false) + assert.match(answer.reason, /boom/) +}) + +test('a deliberate { ok: false } keeps its reason for team_sync_state', async () => { + register('uo', { ...ok(), getTeams: async () => ({ ok: false, reason: 'cache cold' }) }) + assert.deepEqual(await teamProvider.getTeams(), { ok: false, reason: 'cache cold' }) +}) + +test('a missing ok field is not read as authority', async () => { + register('uo', { ...ok(), getTeams: async () => ({ teams: [] }) }) + const answer = await teamProvider.getTeams() + assert.equal(answer.ok, false, 'a forgotten field must not become an authoritative empty list') +}) + +test('a bare array — the shape the envelope exists to outlaw — is a refusal', async () => { + register('uo', { ...ok(), getTeams: async () => [] }) + const answer = await teamProvider.getTeams() + assert.equal(answer.ok, false) + assert.match(answer.reason, /not an envelope/) +}) + +test('null, undefined and a string are all refusals', async () => { + for (const bad of [null, undefined, 'ok', 42]) { + register('uo', { ...ok(), getTeams: async () => bad }) + // eslint-disable-next-line no-await-in-loop + assert.equal((await teamProvider.getTeams()).ok, false, `${String(bad)} must not be authoritative`) + registries._reset() + } +}) + +test('ok:true with no teams array is a refusal, not zero teams', async () => { + register('uo', { ...ok(), getTeams: async () => ({ ok: true }) }) + const answer = await teamProvider.getTeams() + assert.equal(answer.ok, false) + assert.match(answer.reason, /no teams array/) +}) + +test('an ok answer with a genuinely empty list stays ok — §2.4 decides what to do with it', async () => { + register('uo', { ...ok(), getTeams: async () => ({ ok: true, teams: [] }) }) + const answer = await teamProvider.getTeams() + assert.equal(answer.ok, true, 'this file must not second-guess an authoritative empty answer') + assert.deepEqual(answer.teams, []) +}) + +// ── Malformed rows fail the call rather than being salvaged ──────────────── + +test('a team with no externalId fails the whole call', async () => { + register('uo', { ...ok(), getTeams: async () => ({ ok: true, teams: [{ name: 'Nameless' }] }) }) + const answer = await teamProvider.getTeams() + assert.equal(answer.ok, false) + assert.match(answer.reason, /no externalId/) +}) + +test('a team with no name fails the whole call', async () => { + register('uo', { ...ok(), getTeams: async () => ({ ok: true, teams: [{ externalId: 'g1', name: ' ' }] }) }) + assert.match((await teamProvider.getTeams()).reason, /"g1" has no name/) +}) + +test('one unreadable member refuses the roster rather than dropping the member', async () => { + // Dropping it would be indistinguishable, downstream, from the member leaving — + // the sync would mark them departed on the strength of a malformed payload. + register('uo', { + ...ok(), + getTeamMembers: async () => ({ ok: true, members: [{ memberKey: '0x1' }, { displayName: 'ghost' }] }), + }) + const answer = await teamProvider.getTeamMembers('g1') + assert.equal(answer.ok, false) + assert.equal(answer.members, undefined) +}) + +test('a duplicated memberKey is refused rather than collapsed', async () => { + register('uo', { + ...ok(), + getTeamMembers: async () => ({ ok: true, members: [{ memberKey: '0x1' }, { memberKey: '0x1' }] }), + }) + assert.match((await teamProvider.getTeamMembers('g1')).reason, /appears twice/) +}) + +// ── Normalisation of a good answer ───────────────────────────────────────── + +test('team fields are trimmed, and meta is passed through opaquely', async () => { + register('uo', { + ...ok(), + getTeams: async () => ({ + ok: true, + teams: [{ externalId: ' g1 ', name: ' The Silver Hand ', abbr: ' TSH ', meta: { crest: 7 } }], + }), + }) + const { teams } = await teamProvider.getTeams() + assert.deepEqual(teams, [{ externalId: 'g1', name: 'The Silver Hand', abbr: 'TSH', meta: { crest: 7 } }]) +}) + +test('a non-object meta is dropped rather than stored as a scalar', async () => { + register('uo', { + ...ok(), + getTeams: async () => ({ ok: true, teams: [{ externalId: 'g1', name: 'X', meta: 'crest' }] }), + }) + assert.equal((await teamProvider.getTeams()).teams[0].meta, null) +}) + +test('member booleans are coerced and userId is accepted only as a positive integer', async () => { + register('uo', { + ...ok(), + getTeamMembers: async () => ({ + ok: true, + members: [ + { memberKey: '0x1', displayName: 'Aldric', rankLabel: 'Warlord', leader: 1, online: 'yes', userId: 7 }, + { memberKey: '0x2', userId: 0 }, + { memberKey: '0x3', userId: '7' }, + { memberKey: '0x4', userId: 1.5 }, + ], + }), + }) + const { members } = await teamProvider.getTeamMembers('g1') + assert.equal(members[0].leader, true) + assert.equal(members[0].online, true) + assert.equal(members[0].userId, 7) + assert.equal(members[1].userId, null, '0 is not a user id') + assert.equal(members[2].userId, null, 'a numeric string is not a resolved link') + assert.equal(members[3].userId, null) + // Absent optional fields become null rather than undefined, so a column write + // does not depend on the module having spelled the key. + assert.equal(members[1].displayName, null) + assert.equal(members[1].rankLabel, null) +}) + +test('complete defaults to true and is honoured when false', async () => { + register('uo', ok()) + assert.equal((await teamProvider.getTeams()).complete, true) + registries._reset() + + register('uo', { ...ok(), getTeams: async () => ({ ok: true, complete: false, teams: [] }) }) + assert.equal((await teamProvider.getTeams()).complete, false) +}) + +test('duplicate leaders are collapsed and blanks refused', async () => { + register('uo', { ...ok(), getTeamLeaders: async () => ({ ok: true, leaders: ['0x1', '0x1', ' 0x2 '] }) }) + assert.deepEqual((await teamProvider.getTeamLeaders('g1')).leaders, ['0x1', '0x2']) + registries._reset() + + register('uo', { ...ok(), getTeamLeaders: async () => ({ ok: true, leaders: ['0x1', ''] }) }) + assert.equal((await teamProvider.getTeamLeaders('g1')).ok, false) +}) + +test('the external id is passed through to the module unchanged', async () => { + const seen = [] + register('uo', { ...ok(), getTeamMembers: async (id) => { seen.push(id); return { ok: true, members: [] } } }) + await teamProvider.getTeamMembers('g-42') + assert.deepEqual(seen, ['g-42']) +}) + +test('providerModuleId names the registrant, which is what sync state is keyed on', async () => { + register('uo', ok()) + assert.equal(teamProvider.providerModuleId(), 'uo') +}) + +// ── The timeout ──────────────────────────────────────────────────────────── + +test('a provider that never answers becomes a refusal at the deadline', async (t) => { + // Mocked timers rather than a real ten-second wait: this exercises the + // production path exactly — the same setTimeout, the same deadline — without + // putting ten seconds into every CI run. + t.mock.timers.enable({ apis: ['setTimeout'] }) + register('uo', { ...ok(), getTeams: () => new Promise(() => {}) }) + + const pending = teamProvider.getTeams() + t.mock.timers.tick(teamProvider.CALL_TIMEOUT_MS) + + const answer = await pending + assert.equal(answer.ok, false) + assert.match(answer.reason, /did not answer within 10000ms/) + assert.equal(answer.teams, undefined, 'a hung module never produces data') +}) + +test('a hung call does not hold the process open until its deadline', async () => { + // The timer is unreffed, so a call left pending at shutdown cannot keep the + // event loop alive. Asserted directly, because the symptom — a test FILE that + // passes in milliseconds and then sits for ten seconds — is invisible in a + // green summary. + register('uo', { ...ok(), getTeams: () => new Promise(() => {}) }) + const before = process.getActiveResourcesInfo().filter((r) => r === 'Timeout').length + teamProvider.getTeams() + await new Promise((resolve) => { setImmediate(resolve) }) + const after = process.getActiveResourcesInfo().filter((r) => r === 'Timeout').length + assert.equal(after, before, 'the deadline timer must not count as an active resource') +}) + +test('the budget is the documented ten seconds', () => { + assert.equal(teamProvider.CALL_TIMEOUT_MS, 10_000) +}) diff --git a/server/test/teamRoutes.test.js b/server/test/teamRoutes.test.js new file mode 100644 index 0000000..4987ed2 --- /dev/null +++ b/server/test/teamRoutes.test.js @@ -0,0 +1,304 @@ +// The Team API's access boundaries, exercised through the real routers +// (docs/website/TEAMS.md §2.11). +// +// The models are stubbed; what is under test is the wiring — which tier a route +// sits behind, what a hidden Team does to a public caller, and the one route that +// is admin-only inside a staff-wide group. Those are the properties a reviewer +// cannot check by reading a controller in isolation, because they are decided by +// the mount table and by a role read at request time. +process.env.DB_HOST = '127.0.0.1' +process.env.DB_PORT = '59999' + +const { test, after, afterEach } = require('node:test') +const assert = require('node:assert/strict') + +const { startApp } = require('./_helper') +const publicRouter = require('../src/router/v1/public') +const playerRouter = require('../src/router/v1/player') +const adminRouter = require('../src/router/v1/admin') +const sessionService = require('../src/auth/session.service') +const users = require('../src/model/users/users.model') +const teams = require('../src/model/teams/teams.model') +const teamsDbModule = require('../src/model/teams/teams.db') +const moderation = require('../src/model/teams/teamModeration.model') +const teamSync = require('../src/model/teams/teamSync.model') +const activity = require('../src/model/activity/activity.model') +const settings = require('../src/model/settings/settings.model') +const db = require('../src/utils/db') + +after(() => db.close()) + +const saved = [] +function patch(mod, name, fn) { + saved.push([mod, name, mod[name]]) + mod[name] = fn +} +afterEach(() => { + while (saved.length) { + const [mod, name, fn] = saved.pop() + mod[name] = fn + } +}) + +function signInAs(user) { + patch(sessionService, 'validateSession', () => ({ userId: user.id, sessionId: 's1', createdAt: Date.now(), authMethod: 'jwt' })) + patch(sessionService, 'isSessionRevoked', async () => false) + patch(sessionService, 'sessionMeta', () => ({})) + patch(users, 'getById', async () => user) + // Every staff action writes the audit log, and the real one inserts a row. It + // swallows its own errors, so an unstubbed call is not a failure — it is ten + // seconds of connection retries against the dead pool, per test. + patch(activity, 'log', async () => {}) +} + +// siteMode reads settings; keep the public tier out of maintenance. +const liveSite = () => patch(settings, 'get', async () => 'live') + +const admin = { id: 1, username: 'root', role: 'admin', status: 'active' } +const moderator = { id: 2, username: 'mod1', role: 'moderator', status: 'active' } +const player = { id: 3, username: 'ada', role: 'player', status: 'active' } + +async function withApp(mountPath, router, fn) { + const app = await startApp((a) => a.use(mountPath, router)) + try { + return await fn(app) + } finally { + await app.close() + } +} + +const get = (app, path, init) => fetch(`${app.url}${path}`, init) +const post = (app, path, body) => fetch(`${app.url}${path}`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body || {}), +}) + +// ── The public tier is anonymous, and hidden means absent ────────────────── + +test('the public Team routes need no session', async () => { + liveSite() + patch(teams, 'listPublic', async () => ({ teams: [{ slug: 'a' }], total: 1, stale: false, lastSyncAt: null })) + await withApp('/api/v1/public', publicRouter, async (app) => { + const res = await get(app, '/api/v1/public/teams') + assert.equal(res.status, 200) + const body = await res.json() + assert.equal(body.total, 1) + assert.equal(body.stale, false, 'freshness travels with every public payload') + }) +}) + +test('a hidden Team is a 404 to the public, indistinguishable from a missing one', async () => { + liveSite() + // The model returns null for hidden and for missing alike; the route must not + // tell them apart either, or "absent from every public surface" leaks the fact + // that the Team exists. + patch(teams, 'getPublic', async () => null) + patch(teams, 'rosterPublic', async () => null) + await withApp('/api/v1/public', publicRouter, async (app) => { + assert.equal((await get(app, '/api/v1/public/teams/admin')).status, 404) + assert.equal((await get(app, '/api/v1/public/teams/admin/members')).status, 404) + }) +}) + +test('the index and the by-slug lookup agree about what exists', async () => { + // Found live: the index was keyed on a registered provider while the lookup + // goes by slug, so with the module uninstalled `/teams` was empty while + // `/teams/:slug/members` served a full roster — the index denying a Team that + // direct URLs answered for. The rows are core's and outlive the module that + // filled them; `configured: false` is how a client learns the projection is no + // longer maintained. + const rows = [ + { id: 1, slug: 'the-silver-hand', name: 'The Silver Hand', status: 'active', hidden: 0, member_count: 2 }, + { id: 2, slug: 'admin', name: 'Admin', status: 'active', hidden: 1, member_count: 1 }, + ] + patch(teamsDbModule, 'allActive', async () => rows) + patch(teamsDbModule, 'findBySlug', async (slug) => rows.find((r) => r.slug === slug)) + patch(teamsDbModule, 'syncState', async () => null) + + await withApp('/api/v1/public', publicRouter, async (app) => { + liveSite() + const list = await (await get(app, '/api/v1/public/teams')).json() + assert.equal(list.total, 1, 'the hidden Team is absent from the index') + assert.equal(list.configured, false, 'with no provider, the projection is reported unmaintained') + assert.equal(list.teams[0].slug, 'the-silver-hand') + + // Everything the index lists resolves, and nothing it omits does. + assert.equal((await get(app, '/api/v1/public/teams/the-silver-hand')).status, 200) + assert.equal((await get(app, '/api/v1/public/teams/admin')).status, 404) + }) +}) + +test('a public roster never carries a member key or a user id', async () => { + liveSite() + patch(teams, 'rosterPublic', async () => ({ + members: [teams.publicMember({ + display_name: 'Aldric', rank_label: 'Warlord', is_leader: 1, online: 1, user_id: 7, member_key: '0x1', + })], + stale: false, + lastSyncAt: null, + })) + await withApp('/api/v1/public', publicRouter, async (app) => { + const body = await (await get(app, '/api/v1/public/teams/x/members')).json() + const [member] = body.members + assert.equal(member.displayName, 'Aldric') + assert.equal(member.linked, true) + assert.equal('userId' in member, false, 'a site account id is not public') + assert.equal('memberKey' in member, false, 'a game-internal identifier is not public') + }) +}) + +// ── The player tier is authenticated, and role-agnostic ─────────────────── + +test('the player Team routes reject an anonymous caller', async () => { + await withApp('/api/v1/player', playerRouter, async (app) => { + assert.equal((await get(app, '/api/v1/player/teams')).status, 401) + }) +}) + +test('staff are a superset of players — an admin reaches their own Teams', async () => { + // The mistake this guards against has been made in this group once already: a + // requireRole('player') here 403s an admin off their own characters. + patch(teams, 'listForUser', async (userId) => ({ teams: [], forUser: userId, stale: false })) + for (const user of [player, moderator, admin]) { + signInAs(user) + // eslint-disable-next-line no-await-in-loop + await withApp('/api/v1/player', playerRouter, async (app) => { + const res = await get(app, '/api/v1/player/teams') + assert.equal(res.status, 200, `${user.role} must reach their own Teams`) + assert.equal((await res.json()).forUser, user.id, 'the handler is self-scoped to the session') + }) + } +}) + +test('the player access route is scoped to the caller, not to a supplied id', async () => { + signInAs(player) + let seen = null + patch(teams, 'accessForUser', async (slug, userId) => { seen = { slug, userId }; return { slug, allowed: true } }) + await withApp('/api/v1/player', playerRouter, async (app) => { + await get(app, '/api/v1/player/teams/the-silver-hand/access?userId=1') + assert.deepEqual(seen, { slug: 'the-silver-hand', userId: player.id }, 'the query string is not an identity') + }) +}) + +// ── The admin tier is staff-wide, with one admin-only action ────────────── + +test('a player is refused the admin Team surface', async () => { + signInAs(player) + await withApp('/api/v1/admin', adminRouter, async (app) => { + assert.equal((await get(app, '/api/v1/admin/teams')).status, 403) + }) +}) + +test('a moderator reaches the review queue — that is who runs it', async () => { + signInAs(moderator) + patch(moderation, 'reviewQueue', async () => [{ id: 1, name: 'Admin', hidden_term: 'admin' }]) + await withApp('/api/v1/admin', adminRouter, async (app) => { + const res = await get(app, '/api/v1/admin/teams/review') + assert.equal(res.status, 200) + assert.equal((await res.json()).teams[0].hidden_term, 'admin') + }) +}) + +test('literal admin paths are not captured by /:id', async () => { + // Express is first-match-wins, and a /:id declared ahead of /review would turn + // the queue into a lookup for a Team whose id is "review" — a 400 from the + // validator, on a route that should have worked. + signInAs(admin) + patch(moderation, 'reviewQueue', async () => []) + patch(moderation, 'listRequests', async () => []) + patch(teamSync, 'reconcileNow', async () => ({ ok: true, created: 0 })) + await withApp('/api/v1/admin', adminRouter, async (app) => { + assert.equal((await get(app, '/api/v1/admin/teams/review')).status, 200) + assert.equal((await get(app, '/api/v1/admin/teams/requests')).status, 200) + assert.equal((await post(app, '/api/v1/admin/teams/resync')).status, 200) + }) +}) + +test('a non-numeric team id is rejected by the validator', async () => { + signInAs(admin) + await withApp('/api/v1/admin', adminRouter, async (app) => { + assert.equal((await get(app, '/api/v1/admin/teams/not-a-number')).status, 400) + }) +}) + +// ── The §2.9 gate, as the route sees it ─────────────────────────────────── + +test('a moderator un-hiding gets a pending result; an admin gets an applied one', async () => { + // The gate is decided from the caller's live role, so this asserts on the ACTOR + // the route handed the model — the thing that actually decides — rather than on + // two sessions swapped mid-test. + const calls = [] + patch(moderation, 'requestOrApply', async ({ actor, action }) => { + calls.push({ role: actor.role, action }) + return actor.role === 'admin' ? { ok: true, pending: false } : { ok: true, pending: true, requestId: 5 } + }) + + signInAs(moderator) + await withApp('/api/v1/admin', adminRouter, async (app) => { + const body = await (await post(app, '/api/v1/admin/teams/1/unhide', { reason: 'legit' })).json() + assert.equal(body.pending, true) + assert.equal(body.requestId, 5) + }) + assert.deepEqual(calls, [{ role: 'moderator', action: 'unhide' }]) +}) + +test('an admin un-hiding applies at once', async () => { + const calls = [] + patch(moderation, 'requestOrApply', async ({ actor, action }) => { + calls.push({ role: actor.role, action }) + return { ok: true, pending: false } + }) + + signInAs(admin) + await withApp('/api/v1/admin', adminRouter, async (app) => { + const body = await (await post(app, '/api/v1/admin/teams/1/unhide', {})).json() + assert.equal(body.pending, false) + }) + assert.deepEqual(calls, [{ role: 'admin', action: 'unhide' }]) +}) + +test('deciding a request is admin-only, inside a staff-wide group', async () => { + // The route is reachable by any staff member; the refusal comes from the model + // checking the role live, which is the design (§2.9) — a demoted moderator + // loses this the moment they are demoted, not when their token expires. + signInAs(moderator) + patch(moderation, 'decide', async ({ actor }) => (actor.role === 'admin' + ? { ok: true, applied: true } + : { ok: false, status: 403, error: 'only an admin may decide a request' })) + + await withApp('/api/v1/admin', adminRouter, async (app) => { + const res = await post(app, '/api/v1/admin/teams/requests/1/decide', { status: 'approved' }) + assert.equal(res.status, 403) + }) +}) + +test('an invalid decision status never reaches the model', async () => { + signInAs(admin) + let called = false + patch(moderation, 'decide', async () => { called = true; return { ok: true } }) + await withApp('/api/v1/admin', adminRouter, async (app) => { + assert.equal((await post(app, '/api/v1/admin/teams/requests/1/decide', { status: 'maybe' })).status, 400) + }) + assert.equal(called, false) +}) + +test('a leadership override requires both a member key and an effect', async () => { + signInAs(admin) + await withApp('/api/v1/admin', adminRouter, async (app) => { + assert.equal((await post(app, '/api/v1/admin/teams/1/leader-override', { effect: 'grant' })).status, 400) + assert.equal((await post(app, '/api/v1/admin/teams/1/leader-override', { memberKey: '0x1' })).status, 400) + assert.equal((await post(app, '/api/v1/admin/teams/1/leader-override', { memberKey: '0x1', effect: 'maybe' })).status, 400) + }) +}) + +test('an empty display name is routed to the CLEAR action, not published as blank', async () => { + signInAs(admin) + let action = null + patch(moderation, 'requestOrApply', async (args) => { action = args.action; return { ok: true, pending: false } }) + await withApp('/api/v1/admin', adminRouter, async (app) => { + await post(app, '/api/v1/admin/teams/1/display-name', { displayName: '' }) + assert.equal(action, 'clear_display_name_override', 'an audit line must not read as publishing a blank name') + + await post(app, '/api/v1/admin/teams/1/display-name', { displayName: 'The Old Guard' }) + assert.equal(action, 'display_name_override') + }) +}) diff --git a/server/test/teamSync.test.js b/server/test/teamSync.test.js new file mode 100644 index 0000000..614f986 --- /dev/null +++ b/server/test/teamSync.test.js @@ -0,0 +1,804 @@ +// The reconciler and its four refusal gates (docs/website/TEAMS.md §2.4). +// +// The db layer is stubbed and an in-memory projection stands in for the tables, +// so these are assertions about the ALGORITHM: which answers are applied, which +// are refused, and what is left untouched when one is refused. The gates are the +// reason the file exists — every one of them is invariant 1 in a different +// costume, and each is easy to "simplify" away by someone who has not seen what +// an empty answer during a cold start does to a site full of rosters. +const { test, beforeEach, afterEach } = require('node:test') +const assert = require('node:assert/strict') + +const registries = require('../src/modules/registries') +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 teamSync = require('../src/model/teams/teamSync.model') + +// ── An in-memory stand-in for the four tables ────────────────────────────── + +let store +const saved = new Map() + +function patch(mod, name, fn) { + if (!saved.has(mod)) saved.set(mod, new Map()) + if (!saved.get(mod).has(name)) saved.get(mod).set(name, mod[name]) + mod[name] = fn +} + +function restore() { + for (const [mod, names] of saved) for (const [name, fn] of names) mod[name] = fn + saved.clear() +} + +function freshStore() { + return { + teams: [], // { id, module_id, external_id, name, abbr, slug, status, ... } + members: new Map(), // teamId -> Map(memberKey -> row) + sync: new Map(), // moduleId -> row + nextId: 1, + } +} + +function membersOf(teamId) { + if (!store.members.has(teamId)) store.members.set(teamId, new Map()) + return store.members.get(teamId) +} + +function stubDb() { + patch(teamsDb, 'activeByModule', async (moduleId) => + store.teams.filter((t) => t.module_id === moduleId && t.status === 'active')) + + patch(teamsDb, 'findActive', async (moduleId, externalId) => + store.teams.find((t) => t.module_id === moduleId && t.external_id === externalId && t.status === 'active')) + + patch(teamsDb, 'findById', async (id) => store.teams.find((t) => t.id === id)) + + patch(teamsDb, 'slugsLike', async (base) => + store.teams.filter((t) => t.slug === base || t.slug.startsWith(`${base}-`)).map((t) => t.slug)) + + patch(teamsDb, 'insertTeam', async (row) => { + const id = store.nextId++ + store.teams.push({ + id, + module_id: row.moduleId, + external_id: row.externalId, + name: row.name, + abbr: row.abbr ?? null, + slug: row.slug, + meta: row.meta ?? null, + status: 'active', + hidden: row.hidden ? 1 : 0, + hidden_reason: row.hiddenReason || null, + hidden_term: row.hiddenTerm || null, + members_empty_since: null, + roster_synced_at: null, + succeeded_by: null, + member_count: 0, + linked_count: 0, + online_count: 0, + }) + return id + }) + + patch(teamsDb, 'updateTeam', async (id, { abbr, meta }) => { + const t = store.teams.find((x) => x.id === id) + if (t) Object.assign(t, { abbr, meta }) + }) + + patch(teamsDb, 'archiveTeam', async (id, reason, succeededBy = null) => { + const t = store.teams.find((x) => x.id === id && x.status === 'active') + if (t) Object.assign(t, { status: 'archived', archived_reason: reason, succeeded_by: succeededBy }) + }) + + patch(teamsDb, 'recount', async (teamId) => { + const t = store.teams.find((x) => x.id === teamId) + if (!t) return + const rows = [...membersOf(teamId).values()].filter((m) => m.status === 'active') + t.member_count = rows.length + t.linked_count = rows.filter((m) => m.user_id != null).length + t.online_count = rows.filter((m) => m.online).length + }) + + patch(teamsDb, 'markRosterSynced', async (teamId) => { + const t = store.teams.find((x) => x.id === teamId) + if (t) t.roster_synced_at = new Date() + }) + + patch(teamsDb, 'setMembersEmptySince', async (teamId, since) => { + const t = store.teams.find((x) => x.id === teamId) + if (t) t.members_empty_since = since + }) + + patch(teamsDb, 'memberKeys', async (teamId) => + [...membersOf(teamId).values()].filter((m) => m.status === 'active').map((m) => m.member_key)) + + patch(teamsDb, 'upsertMember', async (m) => { + const existing = membersOf(m.teamId).get(m.memberKey) + membersOf(m.teamId).set(m.memberKey, { + team_id: m.teamId, + member_key: m.memberKey, + display_name: m.displayName ?? null, + user_id: m.userId ?? null, + // Insert-only, mirroring the ON DUPLICATE KEY UPDATE clause that omits it: + // leadership is getTeamLeaders()'s answer, not the roster's. + is_leader: existing ? existing.is_leader : (m.isLeader ? 1 : 0), + rank_label: m.rankLabel ?? null, + online: m.online ? 1 : 0, + status: 'active', + first_seen_at: existing ? existing.first_seen_at : 'first', + }) + }) + + patch(teamsDb, 'markDeparted', async (teamId, keys) => { + for (const key of keys) { + const row = membersOf(teamId).get(key) + if (row && row.status === 'active') Object.assign(row, { status: 'departed', online: 0 }) + } + }) + + patch(teamsDb, 'setLeaders', async (teamId, leaderKeys) => { + for (const row of membersOf(teamId).values()) row.is_leader = leaderKeys.includes(row.member_key) ? 1 : 0 + }) + + patch(teamsDb, 'setMemberLeader', async (teamId, key, isLeader) => { + const row = membersOf(teamId).get(key) + if (row) row.is_leader = isLeader ? 1 : 0 + }) + + patch(teamsDb, 'syncState', async (moduleId) => store.sync.get(moduleId)) + patch(teamsDb, 'recordAttempt', async (moduleId) => { + const s = store.sync.get(moduleId) || { module_id: moduleId, consecutive_failures: 0 } + s.last_attempt_at = new Date() + store.sync.set(moduleId, s) + }) + patch(teamsDb, 'recordFailure', async (moduleId, error) => { + const s = store.sync.get(moduleId) || { module_id: moduleId, consecutive_failures: 0 } + s.consecutive_failures += 1 + s.last_error = error + store.sync.set(moduleId, s) + }) + patch(teamsDb, 'recordSuccess', async (moduleId) => { + const s = store.sync.get(moduleId) || { module_id: moduleId, consecutive_failures: 0 } + s.consecutive_failures = 0 + s.last_error = null + s.last_success_at = new Date() + store.sync.set(moduleId, s) + }) + patch(teamsDb, 'setPendingEmpty', async (moduleId, since) => { + const s = store.sync.get(moduleId) || { module_id: moduleId, consecutive_failures: 0 } + s.pending_empty_since = since + 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 +// well-formed, so each test only states the part it is about. +function provide(overrides = {}) { + const provider = { + getTeams: async () => ({ ok: true, teams: [] }), + getTeamMembers: async () => ({ ok: true, members: [] }), + getTeamLeaders: async () => ({ ok: true, leaders: [] }), + ...overrides, + } + const api = registries.stage('uo') + api.registerTeamProvider(provider) + registries.apply(api.staged) + return provider +} + +const team = (externalId, name, extra = {}) => ({ externalId, name, abbr: null, meta: null, ...extra }) +const member = (memberKey, extra = {}) => ({ + memberKey, displayName: memberKey, rankLabel: null, leader: false, online: false, userId: null, ...extra, +}) + +const activeTeams = () => store.teams.filter((t) => t.status === 'active') +const activeMembers = (teamId) => [...membersOf(teamId).values()].filter((m) => m.status === 'active') + +beforeEach(() => { + store = freshStore() + registries._reset() + teamSync._reset() + stubDb() + // The settings read is the only other database touch on this path. + patch(settings, 'get', async () => null) +}) + +afterEach(() => { + teamSync._reset() + registries._reset() + restore() +}) + +// ── Gate 1: a failed getTeams() touches nothing ──────────────────────────── + +test('gate 1 — a provider that cannot answer leaves every row untouched', async () => { + provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'The Silver Hand')] }) }) + await teamSync.reconcileNow('setup') + // Compared as JSON on both sides: the rows carry Date objects, and a snapshot + // taken through JSON would otherwise "differ" from the live rows purely by + // having stringified them. + const before = JSON.stringify(store.teams) + assert.equal(store.teams.length, 1) + + registries._reset() + provide({ getTeams: async () => ({ ok: false, reason: 'sidecar unreachable' }) }) + const result = await teamSync.reconcileNow('test') + + assert.equal(result.ok, false) + assert.equal(JSON.stringify(store.teams), before, 'not a single row may change') + assert.equal(store.sync.get('uo').consecutive_failures, 1) + assert.equal(store.sync.get('uo').last_error, 'sidecar unreachable') +}) + +test('gate 1 — a hung or throwing provider is the same refusal, not an empty list', async () => { + provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) }) + await teamSync.reconcileNow('setup') + + registries._reset() + provide({ getTeams: async () => { throw new Error('EPIPE') } }) + await teamSync.reconcileNow('test') + assert.equal(activeTeams().length, 1, 'a thrown error must never read as "no teams"') +}) + +test('failures accumulate and a success clears them', async () => { + provide({ getTeams: async () => ({ ok: false, reason: 'down' }) }) + await teamSync.reconcileNow('a') + await teamSync.reconcileNow('b') + assert.equal(store.sync.get('uo').consecutive_failures, 2) + + registries._reset() + provide() + await teamSync.reconcileNow('c') + assert.equal(store.sync.get('uo').consecutive_failures, 0) + assert.equal(store.sync.get('uo').last_error, null) +}) + +// ── Gate 2: an authoritative empty list is quarantined ───────────────────── + +test('gate 2 — the first empty answer archives nothing', async () => { + provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A'), team('g2', 'B')] }) }) + await teamSync.reconcileNow('setup') + assert.equal(activeTeams().length, 2) + + registries._reset() + provide({ getTeams: async () => ({ ok: true, teams: [] }) }) + const result = await teamSync.reconcileNow('test') + + assert.equal(result.quarantined, true) + assert.equal(activeTeams().length, 2, 'a cold start must not empty the site') + assert.ok(store.sync.get('uo').pending_empty_since, 'the answer is remembered') +}) + +test('gate 2 — a second empty answer, an interval later, is applied', async () => { + provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) }) + await teamSync.reconcileNow('setup') + + registries._reset() + provide({ getTeams: async () => ({ ok: true, teams: [] }) }) + await teamSync.reconcileNow('first empty') + // Age the quarantine past one full interval. + store.sync.get('uo').pending_empty_since = new Date(Date.now() - (teamSync.DEFAULT_INTERVAL_S + 1) * 1000) + + const result = await teamSync.reconcileNow('second empty') + assert.equal(result.archived, 1, 'every team on the shard really did disband') + assert.equal(activeTeams().length, 0) + assert.equal(store.teams[0].archived_reason, 'disbanded') +}) + +test('gate 2 — a second empty answer TOO SOON is still quarantined', async () => { + provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) }) + await teamSync.reconcileNow('setup') + + registries._reset() + provide({ getTeams: async () => ({ ok: true, teams: [] }) }) + await teamSync.reconcileNow('first') + const result = await teamSync.reconcileNow('second, immediately') + + assert.equal(result.quarantined, true, 'two answers a second apart are one cold start, not two') + assert.equal(activeTeams().length, 1) +}) + +test('gate 2 — any non-empty answer clears the quarantine', async () => { + provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) }) + await teamSync.reconcileNow('setup') + + registries._reset() + provide({ getTeams: async () => ({ ok: true, teams: [] }) }) + await teamSync.reconcileNow('empty') + assert.ok(store.sync.get('uo').pending_empty_since) + + registries._reset() + provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) }) + await teamSync.reconcileNow('recovered') + assert.equal(store.sync.get('uo').pending_empty_since, null) +}) + +test('gate 2 — an empty list with nothing held is not a quarantine, just nothing to do', async () => { + provide({ getTeams: async () => ({ ok: true, teams: [] }) }) + const result = await teamSync.reconcileNow('test') + assert.equal(result.ok, true) + assert.notEqual(result.quarantined, true) +}) + +test('an incomplete answer never removes, so an empty partial list is harmless', async () => { + provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) }) + await teamSync.reconcileNow('setup') + + registries._reset() + provide({ getTeams: async () => ({ ok: true, complete: false, teams: [] }) }) + const result = await teamSync.reconcileNow('partial') + assert.equal(result.archived, 0) + assert.equal(activeTeams().length, 1) + assert.ok(!store.sync.get('uo').pending_empty_since, 'no quarantine needed — nothing was at risk') +}) + +// ── Gate 3: one Team's unanswerable roster ───────────────────────────────── + +test('gate 3 — a refused roster leaves that team alone and the others sync', async () => { + provide({ + getTeams: async () => ({ ok: true, teams: [team('g1', 'A'), team('g2', 'B')] }), + getTeamMembers: async (id) => (id === 'g1' + ? { ok: true, members: [member('0x1'), member('0x2')] } + : { ok: true, members: [member('0x9')] }), + }) + await teamSync.reconcileNow('setup') + assert.equal(activeMembers(1).length, 2) + assert.equal(activeMembers(2).length, 1) + + registries._reset() + provide({ + getTeams: async () => ({ ok: true, teams: [team('g1', 'A'), team('g2', 'B')] }), + getTeamMembers: async (id) => (id === 'g1' + ? { ok: false, reason: 'roster unavailable' } + : { ok: true, members: [member('0x9'), member('0xA')] }), + }) + const result = await teamSync.reconcileNow('test') + + assert.equal(activeMembers(1).length, 2, "g1's roster is untouched, not emptied") + assert.equal(activeMembers(2).length, 2, "g2 syncs normally — one team's problem is its own") + assert.equal(result.rosters, 1, 'only one roster was applied') +}) + +test('gate 3 — a refused roster does not bump that team’s freshness stamp', async () => { + provide({ + getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }), + getTeamMembers: async () => ({ ok: true, members: [member('0x1')] }), + }) + await teamSync.reconcileNow('setup') + const syncedAt = store.teams[0].roster_synced_at + assert.ok(syncedAt) + + registries._reset() + provide({ + getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }), + getTeamMembers: async () => ({ ok: false, reason: 'nope' }), + }) + await teamSync.reconcileNow('test') + assert.equal(store.teams[0].roster_synced_at, syncedAt, 'stale must show as stale, not as just-synced') +}) + +test('leadership is a separate answer — a refused one does not demote anybody', async () => { + provide({ + getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }), + getTeamMembers: async () => ({ ok: true, members: [member('0x1'), member('0x2')] }), + getTeamLeaders: async () => ({ ok: true, leaders: ['0x1'] }), + }) + await teamSync.reconcileNow('setup') + assert.equal(membersOf(1).get('0x1').is_leader, 1) + + registries._reset() + provide({ + getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }), + getTeamMembers: async () => ({ ok: true, members: [member('0x1'), member('0x2')] }), + getTeamLeaders: async () => ({ ok: false, reason: 'cannot say' }), + }) + await teamSync.reconcileNow('test') + assert.equal(membersOf(1).get('0x1').is_leader, 1, 'an unanswerable question is not the answer "nobody"') +}) + +test('the roster seeds is_leader on a new row but never overwrites it afterwards', async () => { + // Two writers for one column is how a refused leadership answer becomes a + // silent demotion: the roster would write `leader: false` before the + // authoritative call was even made. Seeding on insert keeps a Team from being + // leaderless while getTeamLeaders() is failing. + provide({ + getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }), + getTeamMembers: async () => ({ ok: true, members: [member('0x1', { leader: true })] }), + getTeamLeaders: async () => ({ ok: false, reason: 'cannot say' }), + }) + await teamSync.reconcileNow('first sync, leadership unanswerable') + assert.equal(membersOf(1).get('0x1').is_leader, 1, 'seeded from the roster rather than left blank') + + registries._reset() + provide({ + getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }), + getTeamMembers: async () => ({ ok: true, members: [member('0x1', { leader: false })] }), + getTeamLeaders: async () => ({ ok: true, leaders: ['0x1'] }), + }) + await teamSync.reconcileNow('roster disagrees with the authority') + assert.equal(membersOf(1).get('0x1').is_leader, 1, 'getTeamLeaders() is path 2, and the roster is not') +}) + +// ── Gate 4: an authoritative empty roster ────────────────────────────────── + +test('gate 4 — the first empty roster departs nobody', async () => { + let members = [member('0x1'), member('0x2')] + provide({ + getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }), + getTeamMembers: async () => ({ ok: true, members }), + }) + await teamSync.reconcileNow('setup') + assert.equal(activeMembers(1).length, 2) + + members = [] + await teamSync.reconcileNow('empty roster') + assert.equal(activeMembers(1).length, 2, 'a cold cache must not empty a roster') + assert.ok(store.teams[0].members_empty_since) +}) + +test('gate 4 — a second empty roster is applied', async () => { + let members = [member('0x1'), member('0x2')] + provide({ + getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }), + getTeamMembers: async () => ({ ok: true, members }), + }) + await teamSync.reconcileNow('setup') + + members = [] + await teamSync.reconcileNow('first empty') + await teamSync.reconcileNow('second empty') + assert.equal(activeMembers(1).length, 0, 'the guild really was emptied') + assert.equal(store.teams[0].member_count, 0) +}) + +test('gate 4 — a non-empty roster clears the quarantine', async () => { + let members = [member('0x1')] + provide({ + getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }), + getTeamMembers: async () => ({ ok: true, members }), + }) + await teamSync.reconcileNow('setup') + + members = [] + await teamSync.reconcileNow('empty') + assert.ok(store.teams[0].members_empty_since) + + members = [member('0x1')] + await teamSync.reconcileNow('recovered') + assert.equal(store.teams[0].members_empty_since, null) +}) + +test('gate 4 — a team that never had members takes an empty roster at once', async () => { + provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) }) + const result = await teamSync.reconcileNow('test') + assert.equal(result.rosters, 1, 'nothing is at risk, so nothing is quarantined') + assert.equal(store.teams[0].members_empty_since, null) +}) + +// ── Ordinary syncing ─────────────────────────────────────────────────────── + +test('a new team is created with a slug, and its roster and counts follow', async () => { + provide({ + getTeams: async () => ({ ok: true, teams: [team('g1', 'The Silver Hand', { abbr: 'TSH' })] }), + getTeamMembers: async () => ({ + ok: true, + members: [member('0x1', { userId: 7, online: true }), member('0x2')], + }), + getTeamLeaders: async () => ({ ok: true, leaders: ['0x1'] }), + }) + const result = await teamSync.reconcileNow('test') + + assert.equal(result.created, 1) + const row = store.teams[0] + assert.equal(row.slug, 'the-silver-hand') + assert.equal(row.member_count, 2) + assert.equal(row.linked_count, 1) + assert.equal(row.online_count, 1) + assert.equal(membersOf(1).get('0x1').is_leader, 1) +}) + +test('a member who disappears from a complete roster is departed, not deleted', async () => { + let members = [member('0x1'), member('0x2')] + provide({ + getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }), + getTeamMembers: async () => ({ ok: true, members }), + }) + await teamSync.reconcileNow('setup') + + members = [member('0x1')] + await teamSync.reconcileNow('test') + assert.equal(membersOf(1).get('0x2').status, 'departed', 'the row survives so history and rejoins do') + assert.equal(activeMembers(1).length, 1) +}) + +test('a rejoining member revives their row and keeps their first_seen_at', async () => { + let members = [member('0x1'), member('0x2')] + provide({ + getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }), + getTeamMembers: async () => ({ ok: true, members }), + }) + await teamSync.reconcileNow('setup') + members = [member('0x1')] + await teamSync.reconcileNow('leaves') + members = [member('0x1'), member('0x2')] + await teamSync.reconcileNow('returns') + + assert.equal(membersOf(1).get('0x2').status, 'active') + assert.equal(membersOf(1).get('0x2').first_seen_at, 'first', 'a rejoin is a revived row, not a second one') +}) + +test('an incomplete roster adds and updates but removes nothing', async () => { + provide({ + getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }), + getTeamMembers: async () => ({ ok: true, members: [member('0x1'), member('0x2')] }), + }) + await teamSync.reconcileNow('setup') + + registries._reset() + provide({ + getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }), + getTeamMembers: async () => ({ ok: true, complete: false, members: [member('0x3')] }), + }) + await teamSync.reconcileNow('partial') + assert.equal(activeMembers(1).length, 3, 'a partial answer is not a claim about who is absent') +}) + +test('a team absent from a complete list is archived as disbanded', async () => { + provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A'), team('g2', 'B')] }) }) + await teamSync.reconcileNow('setup') + + registries._reset() + provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) }) + await teamSync.reconcileNow('test') + + assert.equal(activeTeams().length, 1) + const archived = store.teams.find((t) => t.external_id === 'g2') + assert.equal(archived.status, 'archived') + assert.equal(archived.archived_reason, 'disbanded') +}) + +// ── The rename rule (§2.2) ───────────────────────────────────────────────── + +test('a renamed team is archived and succeeded, never edited in place', async () => { + provide({ + getTeams: async () => ({ ok: true, teams: [team('g1', 'The Silver Hand')] }), + getTeamMembers: async () => ({ ok: true, members: [member('0x1')] }), + }) + await teamSync.reconcileNow('setup') + + registries._reset() + provide({ + getTeams: async () => ({ ok: true, teams: [team('g1', 'The Golden Hand')] }), + getTeamMembers: async () => ({ ok: true, members: [member('0x1')] }), + }) + const result = await teamSync.reconcileNow('rename') + + assert.equal(result.renamed, 1) + const [old_, next] = store.teams + assert.equal(old_.name, 'The Silver Hand', 'the name is immutable for the life of the row') + assert.equal(old_.status, 'archived') + assert.equal(old_.archived_reason, 'renamed') + assert.equal(old_.succeeded_by, next.id, 'the old slug can explain itself instead of 404ing') + assert.equal(next.name, 'The Golden Hand') + assert.equal(next.slug, 'the-golden-hand') + assert.equal(next.status, 'active') +}) + +test('a rename back to a previous name does not reuse the retired slug', async () => { + const names = ['Alpha', 'Beta', 'Alpha'] + let i = 0 + provide({ getTeams: async () => ({ ok: true, teams: [team('g1', names[i])] }) }) + await teamSync.reconcileNow('a') + i = 1 + await teamSync.reconcileNow('b') + i = 2 + await teamSync.reconcileNow('c') + + const slugs = store.teams.map((t) => t.slug) + assert.deepEqual(slugs, ['alpha', 'beta', 'alpha-2']) + assert.equal(new Set(slugs).size, 3, 'an archived team stays readable at its own address') +}) + +test('two teams with the same name get distinct slugs', async () => { + provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'Guard'), team('g2', 'Guard')] }) }) + await teamSync.reconcileNow('test') + assert.deepEqual(store.teams.map((t) => t.slug), ['guard', 'guard-2']) +}) + +test('a name with nothing URL-safe in it still gets an address', async () => { + provide({ getTeams: async () => ({ ok: true, teams: [team('g1', '★☆★')] }) }) + await teamSync.reconcileNow('test') + assert.equal(store.teams[0].slug, 'team') + 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) ────────────────────────────────────────────────────────── + +test('an unknown event kind is rejected', async () => { + provide() + await assert.rejects(() => teamSync.publish({ kind: 'team.exploded', externalId: 'g1' }), /unknown event kind/) +}) + +test('an event with no externalId is rejected', async () => { + provide() + await assert.rejects(() => teamSync.publish({ kind: 'team.member.added' }), /no externalId/) +}) + +test('team.disbanded never archives — it asks for a reconciliation', async () => { + provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) }) + await teamSync.reconcileNow('setup') + + await teamSync.publish({ kind: 'team.disbanded', externalId: 'g1' }) + assert.equal(activeTeams().length, 1, 'destruction is never driven by a delta that may be a repeat') +}) + +test('team.created does not invent a team', async () => { + provide() + await teamSync.publish({ kind: 'team.created', externalId: 'brand-new' }) + assert.equal(store.teams.length, 0, 'a team built from a delta has no name, roster or leaders') +}) + +test('a member delta applies at once for a known team and updates the counts', async () => { + provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) }) + await teamSync.reconcileNow('setup') + + await teamSync.publish({ + kind: 'team.member.added', externalId: 'g1', memberKey: '0x5', displayName: 'Ada', userId: 3, + }) + assert.equal(activeMembers(1).length, 1) + assert.equal(membersOf(1).get('0x5').display_name, 'Ada') + assert.equal(store.teams[0].member_count, 1) + assert.equal(store.teams[0].linked_count, 1) + + await teamSync.publish({ kind: 'team.member.removed', externalId: 'g1', memberKey: '0x5' }) + assert.equal(activeMembers(1).length, 0) + assert.equal(store.teams[0].member_count, 0) +}) + +test('a leadership delta writes is_leader and nothing else', async () => { + provide({ + getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }), + getTeamMembers: async () => ({ ok: true, members: [member('0x1')] }), + }) + await teamSync.reconcileNow('setup') + + await teamSync.publish({ kind: 'team.leader.added', externalId: 'g1', memberKey: '0x1' }) + assert.equal(membersOf(1).get('0x1').is_leader, 1) + assert.equal(activeMembers(1).length, 1, 'promotion is not a join') + + await teamSync.publish({ kind: 'team.leader.removed', externalId: 'g1', memberKey: '0x1' }) + assert.equal(membersOf(1).get('0x1').is_leader, 0) + assert.equal(activeMembers(1).length, 1, 'demotion is not a departure') +}) + +test('a leadership delta for an unknown member creates nobody', async () => { + provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) }) + await teamSync.reconcileNow('setup') + await teamSync.publish({ kind: 'team.leader.added', externalId: 'g1', memberKey: '0xdead' }) + assert.equal(activeMembers(1).length, 0, 'a promotion is not evidence of membership') +}) + +test('an event for an unknown team asks for a reconciliation instead of guessing', async () => { + provide() + await teamSync.publish({ kind: 'team.member.added', externalId: 'nope', memberKey: '0x1' }) + assert.equal(store.teams.length, 0) +}) + +test('publish is a no-op when no provider is registered', async () => { + await teamSync.publish({ kind: 'team.member.added', externalId: 'g1', memberKey: '0x1' }) + assert.equal(store.teams.length, 0) +}) + +// ── Scheduling ───────────────────────────────────────────────────────────── + +test('a run already in flight is joined rather than run twice', async () => { + let calls = 0 + let release + const gate = new Promise((resolve) => { release = resolve }) + provide({ + getTeams: async () => { + calls += 1 + await gate + return { ok: true, teams: [] } + }, + }) + + const first = teamSync.reconcileNow('first') + const second = await teamSync.reconcileNow('second') + assert.equal(second.joined, true) + release() + await first + assert.equal(calls, 1, 'the lock is what stops two runs writing the same rows') +}) + +test('the poll interval falls back and is floored against a bad setting', async () => { + patch(settings, 'get', async () => 'not a number') + assert.equal(await teamSync.intervalSeconds(), teamSync.DEFAULT_INTERVAL_S) + + patch(settings, 'get', async () => '5') + assert.equal(await teamSync.intervalSeconds(), teamSync.DEFAULT_INTERVAL_S, 'a hot loop is not a valid interval') + + patch(settings, 'get', async () => '120') + assert.equal(await teamSync.intervalSeconds(), 120) + + patch(settings, 'get', async () => { throw new Error('db down') }) + assert.equal(await teamSync.intervalSeconds(), teamSync.DEFAULT_INTERVAL_S) +}) + +test('backoff grows with failures and is capped at the poll interval', async () => { + assert.equal(teamSync.backoffSeconds(0, 900), 900, 'no failures means the ordinary poll') + assert.equal(teamSync.backoffSeconds(1, 900), 30) + assert.equal(teamSync.backoffSeconds(2, 900), 60) + assert.ok(teamSync.backoffSeconds(4, 900) < 900) + assert.equal(teamSync.backoffSeconds(50, 900), 900, 'a module down for a day must recover promptly, not in weeks') +}) + +test('start() is inert with no provider registered', async () => { + await teamSync.start() + assert.equal(store.teams.length, 0) +})