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/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/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/teams.db.js b/server/src/model/teams/teams.db.js index f7f3c27..ce4648c 100644 --- a/server/src/model/teams/teams.db.js +++ b/server/src/model/teams/teams.db.js @@ -24,6 +24,20 @@ async function activeByModule(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( @@ -270,6 +284,7 @@ async function setPendingEmpty(moduleId, since) { module.exports = { activeByModule, + allActive, findActive, findById, findBySlug, 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/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/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/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') + }) +})