feat(teams): Teams as a platform primitive — MODULE_API 1.6.0 (Teams cutover 4/6) #161
@@ -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
|
||||
|
||||
@@ -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
140
client/src/lib/teamAdmin.js
Normal 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 moderator’s un-hide or display-name change '
|
||||
+ 'is filed for approval. Hiding is not gated — suppression is always safe.'
|
||||
|
||||
/** A one-line description of a queued request, for the approval queue. */
|
||||
export function describeRequest(request = {}) {
|
||||
const payload = parsePayload(request.payload)
|
||||
const who = request.requested_username || 'a deleted user'
|
||||
switch (request.action) {
|
||||
case 'unhide':
|
||||
return `${who} asks to publish “${request.team_name}”`
|
||||
case 'display_name_override':
|
||||
return `${who} asks to display “${request.team_name}” as “${payload.displayName || ''}”`
|
||||
case 'clear_display_name_override':
|
||||
return `${who} asks to clear the display name on “${request.team_name}”`
|
||||
default:
|
||||
return `${who} asks for “${request.action}” on “${request.team_name}”`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The payload may arrive parsed or as a JSON string depending on the driver, so
|
||||
* this normalises rather than assuming either. The server has the same note.
|
||||
*/
|
||||
export function parsePayload(payload) {
|
||||
if (payload == null) return {}
|
||||
if (typeof payload === 'object') return payload
|
||||
try {
|
||||
return JSON.parse(payload)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How a member's leadership should read.
|
||||
*
|
||||
* An override is shown AS an override rather than folded into the answer: staff
|
||||
* looking at a roster need to see that a decision was made, not a fact that looks
|
||||
* like the game's.
|
||||
*/
|
||||
export function leadershipOf(member = {}) {
|
||||
if (!member.leaderOverride) {
|
||||
return { isLeader: Boolean(member.isLeader), overridden: false, note: null }
|
||||
}
|
||||
const granted = member.leaderOverride.effect === 'grant'
|
||||
return {
|
||||
isLeader: granted,
|
||||
overridden: true,
|
||||
note: `${granted ? 'Granted' : 'Denied'} by ${member.leaderOverride.by || 'a deleted user'}`
|
||||
+ `${member.leaderOverride.reason ? ` — ${member.leaderOverride.reason}` : ''}`
|
||||
+ ` (the game says ${member.isLeaderSynced ? 'leader' : 'not a leader'})`,
|
||||
}
|
||||
}
|
||||
@@ -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'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
301
client/src/routes/admin/views/TeamsAdmin.jsx
Normal file
301
client/src/routes/admin/views/TeamsAdmin.jsx
Normal 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 }
|
||||
140
client/test/teamAdmin.test.js
Normal file
140
client/test/teamAdmin.test.js
Normal 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/)
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
296
server/src/model/teams/teams.model.js
Normal file
296
server/src/model/teams/teams.model.js
Normal 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,
|
||||
}
|
||||
@@ -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
|
||||
|
||||
211
server/src/router/v1/admin/teams.controller.js
Normal file
211
server/src/router/v1/admin/teams.controller.js
Normal 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,
|
||||
}
|
||||
223
server/src/router/v1/admin/teams.router.js
Normal file
223
server/src/router/v1/admin/teams.router.js
Normal 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 module’s sync state verbatim — last attempt, last success, consecutive failures and the last error — which is what an operator debugging a stale projection needs.'
|
||||
// #swagger.parameters['archived'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Set to 1 to include archived Teams.' }
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Teams and sync state', content: { "application/json": { schema: { $ref: "#/components/schemas/AdminTeamList" } } } } */
|
||||
query('archived').optional().isIn(['0', '1']),
|
||||
validate,
|
||||
ctrl.listTeams,
|
||||
)
|
||||
|
||||
teamsRouter.post(
|
||||
'/resync',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'Run a reconciliation now'
|
||||
// #swagger.description = 'Awaited, so the response carries the outcome including the provider’s own error when it refused. The four refusal gates still apply — a manual resync cannot make core act on an answer it does not trust.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The reconciliation result', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamResyncResult" } } } } */
|
||||
ctrl.resync,
|
||||
)
|
||||
|
||||
teamsRouter.get(
|
||||
'/review',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'The reserved-name review queue'
|
||||
// #swagger.description = 'Teams auto-hidden because their name matched a reserved term, each showing which term matched. A Team a human has already ruled on leaves the queue and is never re-hidden by a later sweep.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Auto-hidden Teams awaiting review', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamReviewQueue" } } } } */
|
||||
ctrl.reviewQueue,
|
||||
)
|
||||
|
||||
teamsRouter.get(
|
||||
'/requests',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'The moderation approval queue'
|
||||
// #swagger.description = 'Requests filed by moderators for the three actions that publish untrusted game-sourced strings. Decided rows are kept — the record that a moderator asked to publish a name and an admin refused is the part worth having.'
|
||||
// #swagger.parameters['status'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'pending (default) | approved | rejected | withdrawn | all' }
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Moderation requests', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamRequestQueue" } } } } */
|
||||
query('status').optional().isIn(['pending', 'approved', 'rejected', 'withdrawn', 'all']),
|
||||
validate,
|
||||
ctrl.listRequests,
|
||||
)
|
||||
|
||||
teamsRouter.post(
|
||||
'/requests/:id/decide',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'Approve or reject a moderation request (admin only)'
|
||||
// #swagger.description = 'Admin only, checked live against the database rather than from a token claim. Approving applies the action; rejecting keeps the row and changes nothing. A request already decided returns 409, so two admins deciding at once cannot double-apply.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Request id.' }
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamDecideRequest" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Decided', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Only an admin may decide a request', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such request', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'Already decided', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt({ min: 1 }).toInt(),
|
||||
body('status').isIn(['approved', 'rejected']),
|
||||
body('note').optional().isString().trim().isLength({ max: 255 }),
|
||||
validate,
|
||||
ctrl.decideRequest,
|
||||
)
|
||||
|
||||
// ── :id paths ──────────────────────────────────────────────────────────────
|
||||
|
||||
teamsRouter.get(
|
||||
'/:id',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'Get one Team, with its roster, grant ledger and pending requests'
|
||||
// #swagger.description = 'The roster carries the resolved leadership and what the game actually said, so an override is visible as a decision rather than presented as fact. Departed members are included.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' }
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The Team', content: { "application/json": { schema: { $ref: "#/components/schemas/AdminTeam" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt({ min: 1 }).toInt(),
|
||||
validate,
|
||||
ctrl.getTeam,
|
||||
)
|
||||
|
||||
teamsRouter.get(
|
||||
'/:id/grants',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'The full forum-grant ledger for a Team, revoked rows included'
|
||||
// #swagger.description = 'The structured record the access resolver reads. The grant/revoke flow itself lands in the forum phase; this is the read side.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' }
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The grant ledger', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamGrantLedger" } } } } */
|
||||
param('id').isInt({ min: 1 }).toInt(),
|
||||
validate,
|
||||
ctrl.grants,
|
||||
)
|
||||
|
||||
teamsRouter.post(
|
||||
'/:id/archive',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'Archive a Team (staff)'
|
||||
// #swagger.description = 'Not gated: archiving withdraws a Team from public surfaces rather than publishing anything.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' }
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamReasonRequest" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Archived', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt({ min: 1 }).toInt(),
|
||||
body('reason').optional().isString().trim().isLength({ max: 255 }),
|
||||
validate,
|
||||
ctrl.archive,
|
||||
)
|
||||
|
||||
teamsRouter.post(
|
||||
'/:id/hide',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'Hide a Team from public surfaces (staff)'
|
||||
// #swagger.description = 'Deliberately NOT gated. Publishing untrusted data needs a second pair of eyes; withdrawing it needs to be possible at once, by whoever is on duty.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' }
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamReasonRequest" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Hidden', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt({ min: 1 }).toInt(),
|
||||
body('reason').optional().isString().trim().isLength({ max: 255 }),
|
||||
validate,
|
||||
ctrl.hide,
|
||||
)
|
||||
|
||||
teamsRouter.post(
|
||||
'/:id/unhide',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'Un-hide a Team — admin applies, moderator requests'
|
||||
// #swagger.description = 'One of the three gated actions: it publishes a name that tripped the impersonation list. An admin applies it at once; a moderator files a pending request and nothing changes publicly until an admin approves.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' }
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamReasonRequest" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Applied, or filed for approval — see `pending`', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamModerationResult" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt({ min: 1 }).toInt(),
|
||||
body('reason').optional().isString().trim().isLength({ max: 255 }),
|
||||
validate,
|
||||
ctrl.unhide,
|
||||
)
|
||||
|
||||
teamsRouter.post(
|
||||
'/:id/display-name',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'Set or clear a Team’s display name — admin applies, moderator requests'
|
||||
// #swagger.description = 'Gated for the same reason as un-hiding: it substitutes free text into the same public surfaces. Identity is untouched — the Team’s `name` stays frozen for the life of the row, and only what is rendered changes. An empty displayName clears the override.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' }
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamDisplayNameRequest" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Applied, or filed for approval — see `pending`', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamModerationResult" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt({ min: 1 }).toInt(),
|
||||
body('displayName').optional({ nullable: true }).isString().trim().isLength({ max: 160 }),
|
||||
body('reason').optional().isString().trim().isLength({ max: 255 }),
|
||||
validate,
|
||||
ctrl.displayName,
|
||||
)
|
||||
|
||||
teamsRouter.post(
|
||||
'/:id/leader-override',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'Grant or deny leadership for one member (staff)'
|
||||
// #swagger.description = 'Applied on top of the synced value at READ time; the projection is never mutated. That is what makes an override survive a resync — one written into team_members would be undone by the next reconciliation. Not gated: it publishes no game-sourced string.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' }
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamLeaderOverrideRequest" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Override set', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt({ min: 1 }).toInt(),
|
||||
body('memberKey').isString().trim().isLength({ min: 1, max: 191 }),
|
||||
body('effect').isIn(['grant', 'deny']),
|
||||
body('reason').optional().isString().trim().isLength({ max: 255 }),
|
||||
validate,
|
||||
ctrl.setLeaderOverride,
|
||||
)
|
||||
|
||||
teamsRouter.delete(
|
||||
'/:id/leader-override/:memberKey',
|
||||
// #swagger.tags = ['Admin · Teams']
|
||||
// #swagger.summary = 'Clear a leadership override (staff)'
|
||||
// #swagger.description = 'The member reverts to whatever the game says at the next read; nothing in the projection changes, because nothing in it was ever changed.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' }
|
||||
// #swagger.parameters['memberKey'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The module’s member key.' }
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Override cleared', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such override', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt({ min: 1 }).toInt(),
|
||||
param('memberKey').isString().trim().isLength({ min: 1, max: 191 }),
|
||||
validate,
|
||||
ctrl.clearLeaderOverride,
|
||||
)
|
||||
|
||||
module.exports = teamsRouter
|
||||
@@ -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
|
||||
|
||||
28
server/src/router/v1/player/teams.controller.js
Normal file
28
server/src/router/v1/player/teams.controller.js
Normal 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 }
|
||||
46
server/src/router/v1/player/teams.router.js
Normal file
46
server/src/router/v1/player/teams.router.js
Normal 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 caller’s Teams, with the reason for each'
|
||||
// #swagger.description = 'Membership and forum grants are separate authority paths, so each Team carries `reason`: membership | grant | both. A Team hidden from public surfaces is still listed here — suppression is a public-surface rule, and a member is not a member of the public.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The caller’s Teams', content: { "application/json": { schema: { $ref: "#/components/schemas/PlayerTeamList" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Account not active (disabled/banned)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
ctrl.listMine,
|
||||
)
|
||||
|
||||
teamsRouter.get(
|
||||
'/:slug/access',
|
||||
// #swagger.tags = ['Player · Teams']
|
||||
// #swagger.summary = 'The caller’s own resolved access on one Team'
|
||||
// #swagger.description = 'Reports viaMembership and viaGrant separately, and keeps both when both hold: the UI presents membership as the current reason while the grant survives as audit history.'
|
||||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The caller’s access', content: { "application/json": { schema: { $ref: "#/components/schemas/PlayerTeamAccess" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
ctrl.getMyAccess,
|
||||
)
|
||||
|
||||
module.exports = teamsRouter
|
||||
@@ -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
|
||||
|
||||
48
server/src/router/v1/public/teams.controller.js
Normal file
48
server/src/router/v1/public/teams.controller.js
Normal 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 }
|
||||
54
server/src/router/v1/public/teams.router.js
Normal file
54
server/src/router/v1/public/teams.router.js
Normal 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
@@ -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 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
304
server/test/teamRoutes.test.js
Normal file
304
server/test/teamRoutes.test.js
Normal 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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user