feat(teams): Teams as a platform primitive — MODULE_API 1.6.0 (Teams cutover 4/6) #161

Merged
whitlocktech merged 45 commits from edge into main 2026-08-19 08:57:13 +00:00
22 changed files with 5763 additions and 0 deletions
Showing only changes of commit cf2666e5bc - Show all commits

View File

@@ -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. */}
<Route path="modules" element={<ModulesAdmin />} />
{/* 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). */}
<Route path="teams" element={<TeamsAdmin />} />
<Route path="account" element={<AccountAdmin />} />
{/* Installed modules' admin pages, at /admin/<id>/…, already inside
RequireAuth + AdminLayout. A module cannot supply its own auth

View File

@@ -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 = {}) => {

140
client/src/lib/teamAdmin.js Normal file
View File

@@ -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 moderators 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'})`,
}
}

View File

@@ -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'] },
],
},
{

View File

@@ -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 (
<span
className="badge"
style={{ color: TONE_COLOR[tone] || 'var(--muted)', borderColor: 'var(--line)', background: 'var(--panel-flat)' }}
>
{children}
</span>
)
}
// ── Sync state ─────────────────────────────────────────────────────────────
function SyncPanel({ sync, syncState, onResync, busy }) {
const freshness = freshnessOf(sync)
return (
<section className="panel" style={{ marginBottom: '1.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '.75rem', flexWrap: 'wrap' }}>
<h2 style={{ margin: 0 }}>Sync</h2>
<Pill tone={freshness.tone}>{freshness.label}</Pill>
<button type="button" className="btn" onClick={onResync} disabled={busy || !sync.configured}>
{busy ? 'Resyncing…' : 'Resync now'}
</button>
</div>
<p className="muted" style={{ marginTop: '.5rem' }}>{freshness.detail}</p>
{syncState && (
<dl className="kv" style={{ marginTop: '.75rem' }}>
<dt>Module</dt><dd>{syncState.moduleId}</dd>
<dt>Last attempt</dt><dd>{dateTime(syncState.lastAttemptAt) || 'never'}</dd>
<dt>Last success</dt><dd>{dateTime(syncState.lastSuccessAt) || 'never'}</dd>
<dt>Consecutive failures</dt><dd>{syncState.consecutiveFailures}</dd>
{syncState.lastError && (
<>
{/* Verbatim. An operator debugging a stale projection needs what the
provider actually said, not a friendlier paraphrase of it. */}
<dt>Last error</dt>
<dd style={{ color: TONE_COLOR.bad }}>{syncState.lastError}</dd>
</>
)}
{syncState.pendingEmptySince && (
<>
<dt>Empty answer held</dt>
<dd>
since {dateTime(syncState.pendingEmptySince)} an authoritative but empty list is
applied only if the next answer agrees.
</dd>
</>
)}
</dl>
)}
</section>
)
}
// ── The reserved-name review queue ─────────────────────────────────────────
function ReviewQueue({ rows, role, onAct, busy }) {
if (!rows.length) return null
return (
<section className="panel" style={{ marginBottom: '1.5rem' }}>
<h2>Names to review</h2>
<p className="muted">
These Teams are hidden from every public surface because their name matched a reserved term.
They work normally for their own members. {GATED_NOTE}
</p>
<table className="table">
<thead>
<tr><th>Name</th><th>Matched</th><th>Members</th><th>Created</th><th /></tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id}>
<td>{row.name}</td>
<td><Pill tone="bad">{row.hidden_term}</Pill></td>
<td>{row.member_count}</td>
<td>{dateTime(row.created_at)}</td>
<td>
<button type="button" className="btn" disabled={busy} onClick={() => onAct(row.id, 'unhide')}>
{gateLabelFor(role, 'Publish')}
</button>
</td>
</tr>
))}
</tbody>
</table>
</section>
)
}
// ── The approval queue ─────────────────────────────────────────────────────
function RequestQueue({ rows, role, onDecide, busy }) {
if (!rows.length) return null
const canDecide = role === 'admin'
return (
<section className="panel" style={{ marginBottom: '1.5rem' }}>
<h2>Awaiting approval</h2>
<p className="muted">
{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.'}
</p>
<ul className="list">
{rows.map((row) => (
<li key={row.id} style={{ display: 'flex', gap: '.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
<span>{describeRequest(row)}</span>
<span className="muted">{dateTime(row.requested_at)}</span>
{row.reason && <span className="muted">{row.reason}</span>}
{canDecide && (
<>
<button type="button" className="btn" disabled={busy} onClick={() => onDecide(row.id, 'approved')}>
Approve
</button>
<button type="button" className="btn" disabled={busy} onClick={() => onDecide(row.id, 'rejected')}>
Reject
</button>
</>
)}
</li>
))}
</ul>
</section>
)
}
// ── One Team ───────────────────────────────────────────────────────────────
function TeamRow({ team, role, onAct, busy }) {
const status = statusOf(team)
return (
<tr>
<td>
{team.displayName}
{team.displayNameOverride && (
<div className="muted" style={{ fontSize: '.85em' }}>
shown instead of {team.name}
</div>
)}
</td>
<td><Pill tone={status.tone}>{status.label}</Pill></td>
<td>{team.memberCount}</td>
<td>{team.linkedCount}</td>
<td>{team.onlineCount}</td>
<td className="muted">{dateTime(team.rosterSyncedAt) || 'never'}</td>
<td>
{team.status === 'active' && (team.hidden
? (
<button type="button" className="btn" disabled={busy} onClick={() => onAct(team.id, 'unhide')}>
{gateLabelFor(role, 'Publish')}
</button>
)
: (
<button type="button" className="btn" disabled={busy} onClick={() => onAct(team.id, 'hide')}>
Hide
</button>
))}
</td>
</tr>
)
}
// ── 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 <ErrorState message={error} />
if (!data) return <Loading />
return (
<div>
<h1>Teams</h1>
{error && <ErrorState message={error} />}
{notice && <p className="notice">{notice}</p>}
<SyncPanel sync={data} syncState={data.syncState} onResync={resync} busy={busy} />
<ReviewQueue rows={review} role={role} onAct={act} busy={busy} />
<RequestQueue rows={requests} role={role} onDecide={decide} busy={busy} />
<section className="panel">
<h2>All Teams</h2>
{!data.teams.length && (
<p className="muted">
{data.configured
? 'No Teams in the projection yet.'
: 'No installed module supplies Teams, so there is nothing to show.'}
</p>
)}
{data.teams.length > 0 && (
<table className="table">
<thead>
<tr>
<th>Name</th><th>Status</th><th>Members</th><th>Linked</th><th>Online</th>
<th>Roster confirmed</th><th />
</tr>
</thead>
<tbody>
{data.teams.map((team) => (
<TeamRow key={team.id} team={team} role={role} onAct={act} busy={busy} />
))}
</tbody>
</table>
)}
</section>
</div>
)
}
export { leadershipOf }

View File

@@ -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/)
})

View File

@@ -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",

View File

@@ -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"

View File

@@ -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,

View File

@@ -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,
}

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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 modules 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 providers 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 Teams 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 Teams `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 modules 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

View File

@@ -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

View File

@@ -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 }

View File

@@ -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 callers 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 callers 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 callers 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 callers 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

View File

@@ -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

View File

@@ -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 }

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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 },
},
},
},
},
}

View File

@@ -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')
})
})