feat(teams): Team core — the reconciler, the four authority paths, and the impersonation controls #151
@@ -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'})`,
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,13 @@
|
||||
// that the two files can drift, so a test asserts they agree
|
||||
// (client/test/moduleRegistry.test.js) rather than trusting a bump to remember
|
||||
// both.
|
||||
// 1.6.0 — the Team surface (docs/website/TEAMS.md Part 11). Nothing on this half
|
||||
// changed yet: the two client additions the version covers are the `team.overview`
|
||||
// and `team.member.row` slots, and a slot can only be declared by the page that
|
||||
// hosts it, which lands with the Team pages in phase 3. This file bumps anyway,
|
||||
// for the reason at the top — the two halves state ONE version, and a module
|
||||
// declares one `coreApi` range against both.
|
||||
//
|
||||
// 1.5.0 — `PublicLayout` takes an optional `shell` prop ('narrow' | 'mid' |
|
||||
// 'wide') that renders the `shell-… page-body` wrapper core's own pages write by
|
||||
// hand. Additive: omitting it is 1.4.0's behaviour, so §3.4's "changing a kit
|
||||
@@ -38,4 +45,4 @@
|
||||
// but the two halves state ONE version: a module declares a single coreApi range
|
||||
// and is served one chunk, so a client that claimed 1.0.0 while the server
|
||||
// answered 1.1.0 would be two answers to one question.
|
||||
export const MODULE_API_VERSION = '1.5.0'
|
||||
export const MODULE_API_VERSION = '1.6.0'
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -845,6 +845,212 @@ CREATE TABLE IF NOT EXISTS installed_modules (
|
||||
INDEX idx_installed_modules_state (state)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ── Teams (docs/website/TEAMS.md Part 2, phase 2) ─────────────────────────────
|
||||
--
|
||||
-- A Team is a core platform entity POPULATED by a module and owned by core. The
|
||||
-- module answers "what teams exist and who is in them" through the team provider
|
||||
-- (MODULE_API.md — registerTeamProvider); core stores the answer, gates it and
|
||||
-- displays it. Every table below is core-internal (TEAMS.md §10.3): a module must
|
||||
-- never read or write one, even though a module is what fills them.
|
||||
--
|
||||
-- Note the tables carry no `<moduleId>_` prefix, correctly — MODULE_API.md §2.6's
|
||||
-- prefix rule binds modules, and these are core's.
|
||||
|
||||
-- The Team itself. `external_id` is the module's own stable identity for it
|
||||
-- (module-uo sends the persistent ServUO Guild.Id) and is opaque to core.
|
||||
--
|
||||
-- `name` is IMMUTABLE for the life of the row (§2.2): a rename archives this row
|
||||
-- with archived_reason='renamed' and creates a new one, so the old Team keeps its
|
||||
-- activity, its grants and its forum as a read-only record. What staff can change
|
||||
-- is display_name_override, which changes what is RENDERED and never what the row
|
||||
-- IS — identity and display are different things and only identity is frozen.
|
||||
CREATE TABLE IF NOT EXISTS teams (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
module_id VARCHAR(32) NOT NULL, -- which module is authoritative
|
||||
external_id VARCHAR(191) NOT NULL, -- opaque to core
|
||||
name VARCHAR(160) NOT NULL,
|
||||
abbr VARCHAR(32) NULL,
|
||||
slug VARCHAR(191) NOT NULL, -- derived from name, unique among ACTIVE teams
|
||||
status ENUM('active','archived') NOT NULL DEFAULT 'active',
|
||||
meta JSON NULL, -- module-supplied, opaque (alliance, crest, …)
|
||||
member_count INT NOT NULL DEFAULT 0, -- denormalised from team_members
|
||||
linked_count INT NOT NULL DEFAULT 0, -- members whose user_id is not null
|
||||
online_count INT NOT NULL DEFAULT 0, -- last known; refreshed by sync
|
||||
-- Public suppression, independent of status. A hidden Team still works
|
||||
-- completely for its own members; it is absent from public surfaces (§2.8).
|
||||
hidden TINYINT(1) NOT NULL DEFAULT 0,
|
||||
hidden_reason ENUM('reserved_name','staff') NULL,
|
||||
hidden_term VARCHAR(64) NULL, -- which reserved term matched, for the review queue
|
||||
-- Set once staff have made an explicit decision about the name. Re-screening
|
||||
-- runs on every sync, and this is what stops it re-hiding a Team a human has
|
||||
-- already allowed — without it the override would be undone every 15 minutes.
|
||||
name_reviewed_at DATETIME NULL,
|
||||
-- PER-TEAM freshness, which team_sync_state cannot express: it holds one row per
|
||||
-- MODULE, and §2.4 gate 3 leaves one Team's roster untouched while the others
|
||||
-- sync normally. Without a per-Team stamp that Team's page would claim the
|
||||
-- module's last success as its own, which is precisely the staleness the rule
|
||||
-- exists to surface. Bumped only when a roster is actually applied.
|
||||
roster_synced_at DATETIME NULL,
|
||||
-- §2.4 gate 4's per-Team quarantine, the twin of team_sync_state.pending_empty_
|
||||
-- since: an authoritative-but-empty ROSTER for a Team that currently has members
|
||||
-- is remembered here and applied only if the next answer agrees.
|
||||
members_empty_since DATETIME NULL,
|
||||
-- Staff may change what is DISPLAYED without touching identity (§2.8.3).
|
||||
display_name_override VARCHAR(160) NULL,
|
||||
-- The successor row written at archive time when this Team was renamed, so the
|
||||
-- old slug can still resolve and explain itself rather than 404 (§2.2).
|
||||
succeeded_by INT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
archived_at DATETIME NULL,
|
||||
archived_reason VARCHAR(64) NULL, -- 'disbanded' | 'renamed' | 'staff'
|
||||
-- A generated column is how "unique among ACTIVE rows only" is expressed without
|
||||
-- a partial index (MariaDB has none): NULL never collides in a UNIQUE key, so
|
||||
-- any number of archived rows may share an external_id.
|
||||
active_key VARCHAR(191) AS (IF(status='active', external_id, NULL)) STORED,
|
||||
active_slug VARCHAR(191) AS (IF(status='active', slug, NULL)) STORED,
|
||||
UNIQUE KEY uq_teams_active (module_id, active_key),
|
||||
UNIQUE KEY uq_teams_active_slug (active_slug),
|
||||
INDEX idx_teams_status (status),
|
||||
INDEX idx_teams_slug (slug),
|
||||
INDEX idx_teams_review (hidden, hidden_reason),
|
||||
-- Self-referential and deliberately SET NULL: a successor may itself be archived
|
||||
-- and eventually pruned, and losing the pointer must not take the old row with it.
|
||||
CONSTRAINT fk_teams_succeeded_by FOREIGN KEY (succeeded_by) REFERENCES teams(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- The membership PROJECTION. Module-authoritative; core only mirrors it, and the
|
||||
-- sync is the ONLY writer (§2.5 path 1). Rows are soft-departed rather than
|
||||
-- deleted so history and rejoin detection survive, and so the activity feed can
|
||||
-- still name a departed member.
|
||||
--
|
||||
-- user_id is resolved BY THE MODULE (it owns the game↔site link table); core never
|
||||
-- resolves it, because doing so would be core reading a module's table by name.
|
||||
CREATE TABLE IF NOT EXISTS team_members (
|
||||
team_id INT NOT NULL,
|
||||
member_key VARCHAR(191) NOT NULL, -- module's stable member id (UO: character serial)
|
||||
display_name VARCHAR(160) NULL, -- in-game name
|
||||
user_id INT NULL, -- resolved by the MODULE; NULL = unlinked
|
||||
is_leader TINYINT(1) NOT NULL DEFAULT 0,
|
||||
rank_label VARCHAR(48) NULL, -- module vocabulary, opaque to core
|
||||
online TINYINT(1) NOT NULL DEFAULT 0,
|
||||
status ENUM('active','departed') NOT NULL DEFAULT 'active',
|
||||
first_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
departed_at DATETIME NULL,
|
||||
PRIMARY KEY (team_id, member_key),
|
||||
-- SET NULL, not CASCADE (§2.10): deleting a site account does not remove the
|
||||
-- character from the guild — only the link to the site goes.
|
||||
CONSTRAINT fk_team_members_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_team_members_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
INDEX idx_team_members_user (user_id),
|
||||
INDEX idx_team_members_status (team_id, status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Freshness of the module's answer. One row per module. THE table invariant 1
|
||||
-- ("module unavailability is staleness, never emptiness") is enforced against.
|
||||
CREATE TABLE IF NOT EXISTS team_sync_state (
|
||||
module_id VARCHAR(32) NOT NULL PRIMARY KEY,
|
||||
last_attempt_at DATETIME NULL,
|
||||
last_success_at DATETIME NULL,
|
||||
consecutive_failures INT NOT NULL DEFAULT 0,
|
||||
last_error VARCHAR(500) NULL,
|
||||
-- The quarantine for §2.4's mass-deletion guard: an authoritative-but-empty
|
||||
-- answer is remembered here and applied only if the NEXT one agrees.
|
||||
pending_empty_since DATETIME NULL,
|
||||
INDEX idx_team_sync_success (last_success_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Staff leadership overrides (§2.5.1), applied ON TOP of the synced value at read
|
||||
-- time. The projection is never mutated: the sync keeps writing what the game
|
||||
-- says and this keeps saying what staff decided, which is the whole point — an
|
||||
-- override the sync clobbered every 15 minutes would be useless.
|
||||
CREATE TABLE IF NOT EXISTS team_leader_overrides (
|
||||
team_id INT NOT NULL,
|
||||
member_key VARCHAR(191) NOT NULL,
|
||||
effect ENUM('grant','deny') NOT NULL,
|
||||
actor_user_id INT NULL,
|
||||
actor_username VARCHAR(32) NULL, -- snapshot, so the record survives the account
|
||||
reason VARCHAR(255) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (team_id, member_key),
|
||||
CONSTRAINT fk_tlo_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_tlo_actor FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Forum access grants (§2.5 path 3) — an append-only grant/revoke ledger that is
|
||||
-- ALSO the current state. An active grant is one with revoked_at IS NULL, and a
|
||||
-- generated column is how "one active grant per (team,user)" is expressed without
|
||||
-- a partial index (MariaDB has none): NULL never collides in a UNIQUE key.
|
||||
--
|
||||
-- The table lands here, in the phase that builds the resolver, so forumAccess() is
|
||||
-- written once and its non-contamination tests are real. The grant/revoke FLOW,
|
||||
-- the per-Team cap and the leader UI are phase 4's; nothing writes this table yet.
|
||||
--
|
||||
-- user_id is NULLABLE and SET NULL, which contradicts the sketch in TEAMS.md §2.5
|
||||
-- and follows §2.10, which settled it deliberately: CASCADE would delete the audit
|
||||
-- trail of who granted whom, which is exactly what an audit exists to survive. The
|
||||
-- username snapshots keep the record readable after the account is gone.
|
||||
--
|
||||
-- THE TWO CANNOT BOTH BE HAD AS §2.5 WROTE THEM, and this is why the marker below
|
||||
-- is a bare flag rather than §2.5's `active_user AS (IF(revoked_at IS NULL,
|
||||
-- user_id, NULL))`. MariaDB refuses `ON DELETE SET NULL` on a foreign key whose
|
||||
-- column is a base column of a STORED generated column (ER_GENERATED_COLUMN_
|
||||
-- FUNCTION_IS_NOT_ALLOWED, 1901) — so §2.5's generated column forces §2.10's
|
||||
-- CASCADE, and the audit trail with it. Deriving the marker from `revoked_at`
|
||||
-- ALONE and putting user_id in the KEY instead gives identical semantics: at most
|
||||
-- one active row per (team_id, user_id), unlimited revoked rows, and user_id free
|
||||
-- to be a SET NULL foreign key. Verified against MariaDB 11 both ways.
|
||||
CREATE TABLE IF NOT EXISTS team_forum_grants (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
team_id INT NOT NULL,
|
||||
user_id INT NULL,
|
||||
username VARCHAR(32) NULL, -- snapshot of the grantee at grant time
|
||||
granted_by INT NULL, -- NULL for a system grant, or a deleted actor
|
||||
granted_username VARCHAR(32) NULL, -- snapshot of the actor
|
||||
granted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
reason VARCHAR(255) NULL,
|
||||
revoked_by INT NULL,
|
||||
revoked_username VARCHAR(32) NULL,
|
||||
revoked_at DATETIME NULL,
|
||||
revoke_reason VARCHAR(255) NULL,
|
||||
active_marker TINYINT(1) AS (IF(revoked_at IS NULL, 1, NULL)) STORED,
|
||||
UNIQUE KEY uq_team_forum_grant_active (team_id, user_id, active_marker),
|
||||
CONSTRAINT fk_tfg_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_tfg_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_tfg_granted_by FOREIGN KEY (granted_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_tfg_revoked_by FOREIGN KEY (revoked_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
INDEX idx_tfg_user (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- The §2.9 approval queue. A MODERATOR performing one of the three actions that
|
||||
-- publish untrusted game-sourced strings creates a pending row here; an ADMIN
|
||||
-- performing one applies it immediately. Rows are kept after a decision — "a
|
||||
-- moderator asked to publish this name and an admin refused" is the record worth
|
||||
-- having.
|
||||
--
|
||||
-- `action` + `payload` means a fourth gated action is an enum value rather than a
|
||||
-- schema change. That is room to extend, not an invitation: nothing else is gated
|
||||
-- today, and nothing should be without asking §2.9's question first.
|
||||
CREATE TABLE IF NOT EXISTS team_moderation_requests (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
team_id INT NOT NULL,
|
||||
action ENUM('unhide','display_name_override','clear_display_name_override') NOT NULL,
|
||||
payload JSON NULL, -- e.g. { "displayName": "…" }
|
||||
reason VARCHAR(255) NULL,
|
||||
requested_by INT NULL,
|
||||
requested_username VARCHAR(32) NULL, -- snapshot (§2.10)
|
||||
requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
status ENUM('pending','approved','rejected','withdrawn') NOT NULL DEFAULT 'pending',
|
||||
decided_by INT NULL,
|
||||
decided_username VARCHAR(32) NULL,
|
||||
decided_at DATETIME NULL,
|
||||
decision_note VARCHAR(255) NULL,
|
||||
CONSTRAINT fk_tmr_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_tmr_requested_by FOREIGN KEY (requested_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_tmr_decided_by FOREIGN KEY (decided_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
INDEX idx_tmr_queue (status, requested_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Migrations for databases created before the wiki upgrade. Each statement uses
|
||||
-- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get
|
||||
-- these columns from the CREATE TABLE above; existing installs get them here.
|
||||
|
||||
@@ -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"
|
||||
|
||||
94
server/src/model/teams/teamAccess.db.js
Normal file
94
server/src/model/teams/teamAccess.db.js
Normal file
@@ -0,0 +1,94 @@
|
||||
// SQL for the two tables the access resolver reads: forum grants (path 3) and
|
||||
// staff leadership overrides (§2.5.1).
|
||||
//
|
||||
// Kept separate from teams.db.js on purpose. The four authority paths are four
|
||||
// tables answering four questions, and the single most important structural rule
|
||||
// in TEAMS.md is that no resolver reads another path's table — a file boundary is
|
||||
// a cheap way to make crossing one visible in a diff.
|
||||
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
// ── team_forum_grants (path 3) ─────────────────────────────────────────────
|
||||
|
||||
const GRANT_COLUMNS = `
|
||||
id, team_id, user_id, username, granted_by, granted_username, granted_at, reason,
|
||||
revoked_by, revoked_username, revoked_at, revoke_reason`
|
||||
|
||||
/** The caller's ACTIVE grant on a team, or undefined. At most one, by the unique key. */
|
||||
async function activeGrant(teamId, userId) {
|
||||
const rows = await query(
|
||||
`SELECT ${GRANT_COLUMNS} FROM team_forum_grants
|
||||
WHERE team_id = ? AND user_id = ? AND revoked_at IS NULL`,
|
||||
[teamId, userId],
|
||||
)
|
||||
return rows[0]
|
||||
}
|
||||
|
||||
/** The whole ledger for a team, revoked rows included — the admin grant view. */
|
||||
async function grantLedger(teamId) {
|
||||
return query(
|
||||
`SELECT ${GRANT_COLUMNS} FROM team_forum_grants WHERE team_id = ? ORDER BY granted_at DESC, id DESC`,
|
||||
[teamId],
|
||||
)
|
||||
}
|
||||
|
||||
/** Active grants only, for the "Forum guests" list and the per-team cap. */
|
||||
async function activeGrants(teamId) {
|
||||
return query(
|
||||
`SELECT ${GRANT_COLUMNS} FROM team_forum_grants WHERE team_id = ? AND revoked_at IS NULL
|
||||
ORDER BY granted_at`,
|
||||
[teamId],
|
||||
)
|
||||
}
|
||||
|
||||
// ── team_leader_overrides (§2.5.1) ─────────────────────────────────────────
|
||||
|
||||
const OVERRIDE_COLUMNS = 'team_id, member_key, effect, actor_user_id, actor_username, reason, created_at'
|
||||
|
||||
async function overridesForTeam(teamId) {
|
||||
return query(`SELECT ${OVERRIDE_COLUMNS} FROM team_leader_overrides WHERE team_id = ? ORDER BY member_key`,
|
||||
[teamId])
|
||||
}
|
||||
|
||||
async function overrideFor(teamId, memberKey) {
|
||||
const rows = await query(
|
||||
`SELECT ${OVERRIDE_COLUMNS} FROM team_leader_overrides WHERE team_id = ? AND member_key = ?`,
|
||||
[teamId, memberKey],
|
||||
)
|
||||
return rows[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Set or replace one override.
|
||||
*
|
||||
* The projection is never touched by this — `team_members.is_leader` keeps saying
|
||||
* what the game says and this keeps saying what staff decided, which is the entire
|
||||
* point (§2.5.1). An override applied INTO the projection would be clobbered by
|
||||
* the next sync, fifteen minutes later.
|
||||
*/
|
||||
async function setOverride({ teamId, memberKey, effect, actorUserId, actorUsername, reason }) {
|
||||
await query(
|
||||
`INSERT INTO team_leader_overrides (team_id, member_key, effect, actor_user_id, actor_username, reason)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
effect = VALUES(effect), actor_user_id = VALUES(actor_user_id),
|
||||
actor_username = VALUES(actor_username), reason = VALUES(reason), created_at = NOW()`,
|
||||
[teamId, memberKey, effect, actorUserId, actorUsername, reason],
|
||||
)
|
||||
}
|
||||
|
||||
async function clearOverride(teamId, memberKey) {
|
||||
const res = await query('DELETE FROM team_leader_overrides WHERE team_id = ? AND member_key = ?',
|
||||
[teamId, memberKey])
|
||||
return res.affectedRows > 0
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
activeGrant,
|
||||
grantLedger,
|
||||
activeGrants,
|
||||
overridesForTeam,
|
||||
overrideFor,
|
||||
setOverride,
|
||||
clearOverride,
|
||||
}
|
||||
131
server/src/model/teams/teamAccess.model.js
Normal file
131
server/src/model/teams/teamAccess.model.js
Normal file
@@ -0,0 +1,131 @@
|
||||
// ── The four authority paths ───────────────────────────────────────────────
|
||||
//
|
||||
// The single most important structural rule in TEAMS.md (§2.5): these are four
|
||||
// tables answering four questions, and **no resolver reads another path's table.**
|
||||
//
|
||||
// 1. Is this account a member? module team_members
|
||||
// 2. Does this account lead the Team? module team_members.is_leader,
|
||||
// plus a staff override
|
||||
// 3. May it use the Team forum? CORE team_forum_grants OR path 1
|
||||
// 4. May it get external-platform CORE, nothing of its own
|
||||
// access? derived
|
||||
//
|
||||
// The temptation this file exists to resist is collapsing 1 and 3 into one
|
||||
// boolean. They answer different questions about different populations: a forum
|
||||
// grant may name any Runic Gateway account, including one with no game identity
|
||||
// at all — that is the point of it, since letting an unlinked guildmate into the
|
||||
// forum must not require a staff ticket. Treating "has forum access" as "is a
|
||||
// member" would put that person on the roster, in the member count, and into the
|
||||
// external-platform grant, which is where it stops being a modelling preference
|
||||
// and becomes an impersonation risk (path 4 below).
|
||||
//
|
||||
// Non-contamination is the invariant: a manual grant never writes the membership
|
||||
// projection, in either direction, ever. Both facts coexist and neither migrates
|
||||
// into the other.
|
||||
|
||||
const accessDb = require('./teamAccess.db')
|
||||
const teamsDb = require('./teams.db')
|
||||
const identities = require('../userIdentities/userIdentities.model')
|
||||
|
||||
/**
|
||||
* Path 3 — forum access. Two reads, OR'd, and nothing else.
|
||||
*
|
||||
* `viaGrant` is reported even when membership also holds, deliberately: both
|
||||
* facts are true, the UI presents membership as the current reason, and the grant
|
||||
* survives as audit history. Collapsing them into one boolean is what loses the
|
||||
* record of who let this person in and why.
|
||||
*/
|
||||
async function forumAccess(teamId, userId) {
|
||||
if (!userId) return { allowed: false, viaMembership: false, viaGrant: false, isLeader: false }
|
||||
|
||||
const [grant, member] = await Promise.all([
|
||||
accessDb.activeGrant(teamId, userId), // path 3's own table
|
||||
teamsDb.activeByUser(teamId, userId), // path 1
|
||||
])
|
||||
|
||||
return {
|
||||
allowed: Boolean(grant) || Boolean(member),
|
||||
viaMembership: Boolean(member),
|
||||
viaGrant: Boolean(grant),
|
||||
isLeader: member ? await isLeader(teamId, member) : false,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Path 2 — leadership, with the staff override applied ON TOP of the synced value
|
||||
* at read time (§2.5.1).
|
||||
*
|
||||
* Applied at read rather than written into the projection because the sync owns
|
||||
* that column and rewrites it every interval. An override that lived in
|
||||
* `team_members` would be undone fifteen minutes after staff set it, which is the
|
||||
* whole reason this is a separate table read here.
|
||||
*/
|
||||
async function isLeader(teamId, member) {
|
||||
if (!member) return false
|
||||
const override = await accessDb.overrideFor(teamId, member.member_key)
|
||||
if (override) return override.effect === 'grant'
|
||||
return Boolean(member.is_leader)
|
||||
}
|
||||
|
||||
/** Leadership for a caller identified by user id rather than by a member row. */
|
||||
async function isLeaderByUser(teamId, userId) {
|
||||
if (!userId) return false
|
||||
const member = await teamsDb.activeByUser(teamId, userId)
|
||||
return isLeader(teamId, member)
|
||||
}
|
||||
|
||||
/**
|
||||
* Path 4 — external-platform eligibility. Computed, no table of its own, and
|
||||
* deliberately blind to path 3.
|
||||
*
|
||||
* The reason, stated so nobody "fixes" it later: an integration cannot verify
|
||||
* that an unlinked, forum-granted account corresponds to a real game member, so
|
||||
* it must not hand that account a privilege on a platform where impersonation has
|
||||
* consequences. A forum is a room on the operator's own site with a known
|
||||
* moderator; a Discord role is an identity claim in someone else's space.
|
||||
*/
|
||||
async function externalEligible(teamId, userId, platform) {
|
||||
if (!userId || !platform) return false
|
||||
const member = await teamsDb.activeByUser(teamId, userId) // path 1 ONLY
|
||||
if (!member || member.user_id == null) return false // must be a LINKED game member
|
||||
const linked = await identities.listForUser(userId)
|
||||
return linked.some((i) => i.provider === platform)
|
||||
}
|
||||
|
||||
/**
|
||||
* A team's roster with overrides folded in, for the admin view and the Team page.
|
||||
*
|
||||
* The rows returned carry `is_leader` as RESOLVED — synced value plus override —
|
||||
* and `is_leader_synced` as what the game actually said, so the admin surface can
|
||||
* show that a decision was made rather than silently presenting it as fact.
|
||||
*/
|
||||
async function rosterWithOverrides(teamId, { includeDeparted = false } = {}) {
|
||||
const [members, overrides] = await Promise.all([
|
||||
teamsDb.membersByTeam(teamId, { includeDeparted }),
|
||||
accessDb.overridesForTeam(teamId),
|
||||
])
|
||||
const byKey = new Map(overrides.map((o) => [o.member_key, o]))
|
||||
return members.map((m) => {
|
||||
const override = byKey.get(m.member_key)
|
||||
return {
|
||||
...m,
|
||||
is_leader_synced: Boolean(m.is_leader),
|
||||
is_leader: override ? override.effect === 'grant' : Boolean(m.is_leader),
|
||||
leader_override: override
|
||||
? { effect: override.effect, reason: override.reason, by: override.actor_username, at: override.created_at }
|
||||
: null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
forumAccess,
|
||||
isLeader,
|
||||
isLeaderByUser,
|
||||
externalEligible,
|
||||
rosterWithOverrides,
|
||||
setLeaderOverride: accessDb.setOverride,
|
||||
clearLeaderOverride: accessDb.clearOverride,
|
||||
grantLedger: accessDb.grantLedger,
|
||||
activeGrants: accessDb.activeGrants,
|
||||
}
|
||||
123
server/src/model/teams/teamModeration.db.js
Normal file
123
server/src/model/teams/teamModeration.db.js
Normal file
@@ -0,0 +1,123 @@
|
||||
// SQL for the reserved-name review queue and the §2.9 approval queue.
|
||||
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
// ── The hide/display state on `teams` ──────────────────────────────────────
|
||||
|
||||
async function setHidden(teamId, { hidden, reason, term }) {
|
||||
await query(
|
||||
'UPDATE teams SET hidden = ?, hidden_reason = ?, hidden_term = ? WHERE id = ?',
|
||||
[hidden ? 1 : 0, hidden ? reason : null, hidden ? term || null : null, teamId],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Record that a human has decided about this name.
|
||||
*
|
||||
* What makes a staff decision STICKY (§2.8.3). Re-screening runs on every sync,
|
||||
* and without this stamp an operator adding a reserved term — or simply renaming
|
||||
* the deployment — would re-hide a Team staff had already allowed, every fifteen
|
||||
* minutes, forever.
|
||||
*/
|
||||
async function markNameReviewed(teamId) {
|
||||
await query('UPDATE teams SET name_reviewed_at = NOW() WHERE id = ?', [teamId])
|
||||
}
|
||||
|
||||
async function setDisplayNameOverride(teamId, displayName) {
|
||||
await query('UPDATE teams SET display_name_override = ? WHERE id = ?', [displayName, teamId])
|
||||
}
|
||||
|
||||
/** Active teams whose name has never been screened by a human. */
|
||||
async function unreviewedActive(moduleId) {
|
||||
return query(
|
||||
`SELECT id, name, hidden, hidden_reason FROM teams
|
||||
WHERE module_id = ? AND status = 'active' AND name_reviewed_at IS NULL`,
|
||||
[moduleId],
|
||||
)
|
||||
}
|
||||
|
||||
/** The reserved-name review queue (§2.8.3). */
|
||||
async function reviewQueue() {
|
||||
return query(
|
||||
`SELECT id, name, slug, hidden_term, display_name_override, member_count, created_at
|
||||
FROM teams
|
||||
WHERE status = 'active' AND hidden = 1 AND hidden_reason = 'reserved_name' AND name_reviewed_at IS NULL
|
||||
ORDER BY created_at DESC`,
|
||||
)
|
||||
}
|
||||
|
||||
// ── team_moderation_requests (§2.9) ────────────────────────────────────────
|
||||
|
||||
const REQUEST_COLUMNS = `
|
||||
id, team_id, action, payload, reason, requested_by, requested_username, requested_at,
|
||||
status, decided_by, decided_username, decided_at, decision_note`
|
||||
|
||||
async function insertRequest({ teamId, action, payload, reason, requestedBy, requestedUsername }) {
|
||||
const res = await query(
|
||||
`INSERT INTO team_moderation_requests
|
||||
(team_id, action, payload, reason, requested_by, requested_username)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[teamId, action, payload == null ? null : JSON.stringify(payload), reason, requestedBy, requestedUsername],
|
||||
)
|
||||
return res.insertId
|
||||
}
|
||||
|
||||
async function findRequest(id) {
|
||||
const rows = await query(`SELECT ${REQUEST_COLUMNS} FROM team_moderation_requests WHERE id = ?`, [id])
|
||||
return rows[0]
|
||||
}
|
||||
|
||||
/** The approval queue. Decided rows are kept — see §2.9 — so `status` is a filter. */
|
||||
async function listRequests({ status = 'pending', limit = 100 } = {}) {
|
||||
const params = []
|
||||
let sql = `SELECT r.${REQUEST_COLUMNS.trim().split(/,\s*/).join(', r.')},
|
||||
t.name AS team_name, t.slug AS team_slug
|
||||
FROM team_moderation_requests r JOIN teams t ON t.id = r.team_id`
|
||||
if (status !== 'all') {
|
||||
sql += ' WHERE r.status = ?'
|
||||
params.push(status)
|
||||
}
|
||||
sql += ' ORDER BY r.requested_at DESC, r.id DESC LIMIT ?'
|
||||
params.push(limit)
|
||||
return query(sql, params)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide a request, but only if it is still pending.
|
||||
*
|
||||
* The `status = 'pending'` guard is the concurrency control: two admins opening
|
||||
* the same queue and both clicking approve would otherwise each apply the action,
|
||||
* and the second would overwrite the first's record of who decided it. The caller
|
||||
* applies the effect only when this reports a row was actually moved.
|
||||
*/
|
||||
async function decideRequest(id, { status, decidedBy, decidedUsername, note }) {
|
||||
const res = await query(
|
||||
`UPDATE team_moderation_requests
|
||||
SET status = ?, decided_by = ?, decided_username = ?, decided_at = NOW(), decision_note = ?
|
||||
WHERE id = ? AND status = 'pending'`,
|
||||
[status, decidedBy, decidedUsername, note, id],
|
||||
)
|
||||
return res.affectedRows > 0
|
||||
}
|
||||
|
||||
/** Pending requests for one team — shown on its admin page so a second is not filed. */
|
||||
async function pendingForTeam(teamId) {
|
||||
return query(
|
||||
`SELECT ${REQUEST_COLUMNS} FROM team_moderation_requests
|
||||
WHERE team_id = ? AND status = 'pending' ORDER BY requested_at`,
|
||||
[teamId],
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
setHidden,
|
||||
markNameReviewed,
|
||||
setDisplayNameOverride,
|
||||
unreviewedActive,
|
||||
reviewQueue,
|
||||
insertRequest,
|
||||
findRequest,
|
||||
listRequests,
|
||||
decideRequest,
|
||||
pendingForTeam,
|
||||
}
|
||||
239
server/src/model/teams/teamModeration.model.js
Normal file
239
server/src/model/teams/teamModeration.model.js
Normal file
@@ -0,0 +1,239 @@
|
||||
// ── Impersonation controls, and the approval gate on them ──────────────────
|
||||
//
|
||||
// TEAMS.md §2.8–§2.9. Two things live here:
|
||||
//
|
||||
// 1. **Auto-hide**, which turns a reserved-name match into a suppressed Team
|
||||
// and a review queue entry rather than into a refusal. Core cannot refuse a
|
||||
// name — the guild exists in the game and core is a mirror of it.
|
||||
//
|
||||
// 2. **The approval gate**, which is scoped to the three actions that RELEASE
|
||||
// untrusted game-sourced strings onto public surfaces, and to nothing else.
|
||||
//
|
||||
// **The gate's scope is the part most likely to be misread.** It is not a general
|
||||
// staff-approval workflow. Ordinary forum grants, leadership overrides, archives
|
||||
// and forum moderation all still apply immediately and are audited, exactly as
|
||||
// before. Three actions are gated, and the question that admits a fourth is
|
||||
// always the same one: *does this publish untrusted game data?*
|
||||
//
|
||||
// - clearing a reserved_name hide — publishes a name that tripped the list
|
||||
// - setting a display_name_override — substitutes free text into the same
|
||||
// public surfaces
|
||||
// - un-hiding a staff-hidden Team — reverses a deliberate suppression
|
||||
//
|
||||
// **Moderator-initiated, admin-approved — never four-eyes on admins.** `users.role`
|
||||
// defaults to admin and `npm run seed` creates exactly one, so most deployments
|
||||
// have precisely one admin. A rule requiring a second would wedge them with no
|
||||
// way out, which is a worse failure than the one it guards against.
|
||||
|
||||
const moderationDb = require('./teamModeration.db')
|
||||
const teamsDb = require('./teams.db')
|
||||
const reservedNames = require('../../utils/reservedNames')
|
||||
const activity = require('../activity/activity.model')
|
||||
const log = require('../../utils/logger')('teams')
|
||||
|
||||
const GATED_ACTIONS = ['unhide', 'display_name_override', 'clear_display_name_override']
|
||||
|
||||
const isAdmin = (actor) => Boolean(actor) && actor.role === 'admin'
|
||||
|
||||
/**
|
||||
* Screen a name and return the columns a create should carry.
|
||||
*
|
||||
* Never throws: screening reads settings, and a database hiccup during a
|
||||
* reconcile must not stop a Team being created. It fails OPEN on the create — the
|
||||
* Team appears — because the re-screen on the next sync will catch it, and a
|
||||
* reconcile that aborts halfway is worse than a name that is public for one
|
||||
* interval. That is a deliberate trade and it is the reason re-screening exists
|
||||
* at all rather than being a create-time-only check.
|
||||
*/
|
||||
async function screenForCreate(name) {
|
||||
try {
|
||||
const { reserved, term } = await reservedNames.screen(name)
|
||||
if (!reserved) return { hidden: false }
|
||||
log.warn('team auto-hidden: its name matched a reserved term', { name, term })
|
||||
return { hidden: true, hiddenReason: 'reserved_name', hiddenTerm: term }
|
||||
} catch (err) {
|
||||
log.error('reserved-name screening failed; the team is created unscreened', {
|
||||
name, message: err.message,
|
||||
})
|
||||
return { hidden: false }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-screen every active Team whose name no human has ruled on.
|
||||
*
|
||||
* Names are immutable per row, so this only ever changes an outcome when the TERM
|
||||
* LIST changed — an operator adding a term, or the deployment being renamed. That
|
||||
* is precisely the case a create-time-only check would miss forever.
|
||||
*
|
||||
* A Team staff have already decided about is skipped, and that stickiness is the
|
||||
* point: without it, an override would be undone on the next sweep.
|
||||
*/
|
||||
async function rescreen(moduleId) {
|
||||
let hidden = 0
|
||||
try {
|
||||
const rows = await moderationDb.unreviewedActive(moduleId)
|
||||
for (const row of rows) {
|
||||
if (row.hidden) continue
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const { reserved, term } = await reservedNames.screen(row.name)
|
||||
if (!reserved) continue
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await moderationDb.setHidden(row.id, { hidden: true, reason: 'reserved_name', term })
|
||||
hidden += 1
|
||||
log.warn('team hidden by a re-screen: the reserved terms changed', { id: row.id, name: row.name, term })
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('re-screening failed', { message: err.message })
|
||||
}
|
||||
return hidden
|
||||
}
|
||||
|
||||
// ── The three gated actions ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Apply a gated action, or file it for approval.
|
||||
*
|
||||
* The role check is answered LIVE against the database on every request by core's
|
||||
* admin middleware, so "is this caller an admin" is not read from a token claim
|
||||
* that a demotion would not have invalidated.
|
||||
*/
|
||||
async function requestOrApply({ req, actor, teamId, action, payload, reason }) {
|
||||
if (!GATED_ACTIONS.includes(action)) throw new Error(`not a gated action: "${action}"`)
|
||||
const team = await teamsDb.findById(teamId)
|
||||
if (!team) return { ok: false, status: 404, error: 'team not found' }
|
||||
|
||||
if (!isAdmin(actor)) {
|
||||
const id = await moderationDb.insertRequest({
|
||||
teamId, action, payload, reason, requestedBy: actor.id, requestedUsername: actor.username,
|
||||
})
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'team.moderation.request',
|
||||
detail: `${actor.username} (#${actor.id}) requested "${action}" on team "${team.name}" (#${teamId})`
|
||||
+ `${reason ? `: "${reason}"` : ''}`,
|
||||
})
|
||||
return { ok: true, pending: true, requestId: id }
|
||||
}
|
||||
|
||||
await applyAction({ req, actor, team, action, payload, reason })
|
||||
return { ok: true, pending: false }
|
||||
}
|
||||
|
||||
/** The effect itself. Reached by an admin directly, or by an approval. */
|
||||
async function applyAction({ req, actor, team, action, payload, reason }) {
|
||||
switch (action) {
|
||||
case 'unhide':
|
||||
await moderationDb.setHidden(team.id, { hidden: false })
|
||||
// A human has now ruled on this name, so no later sweep re-hides it.
|
||||
await moderationDb.markNameReviewed(team.id)
|
||||
break
|
||||
case 'display_name_override':
|
||||
await moderationDb.setDisplayNameOverride(team.id, payload.displayName)
|
||||
await moderationDb.markNameReviewed(team.id)
|
||||
break
|
||||
case 'clear_display_name_override':
|
||||
await moderationDb.setDisplayNameOverride(team.id, null)
|
||||
break
|
||||
default:
|
||||
throw new Error(`not a gated action: "${action}"`)
|
||||
}
|
||||
|
||||
await activity.log({
|
||||
req,
|
||||
action: `team.${action}`,
|
||||
detail: `${actor.username} (#${actor.id}) applied "${action}" to team "${team.name}" (#${team.id})`
|
||||
+ `${payload && payload.displayName ? ` as "${payload.displayName}"` : ''}`
|
||||
+ `${reason ? `: "${reason}"` : ''}`,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide a Team. NOT gated — suppression is always safe (§2.11).
|
||||
*
|
||||
* The asymmetry is the whole design: publishing untrusted data needs a second
|
||||
* pair of eyes, and withdrawing it needs to be possible at once, by whoever is
|
||||
* on duty.
|
||||
*/
|
||||
async function hide({ req, actor, teamId, reason }) {
|
||||
const team = await teamsDb.findById(teamId)
|
||||
if (!team) return { ok: false, status: 404, error: 'team not found' }
|
||||
await moderationDb.setHidden(teamId, { hidden: true, reason: 'staff' })
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'team.hide',
|
||||
detail: `${actor.username} (#${actor.id}) hid team "${team.name}" (#${teamId})`
|
||||
+ `${reason ? `: "${reason}"` : ''}`,
|
||||
})
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide a pending request. Admin only.
|
||||
*
|
||||
* The effect is applied only when the row actually moved out of `pending`, so two
|
||||
* admins deciding the same request race safely: the second is told it was already
|
||||
* decided rather than applying the action a second time.
|
||||
*/
|
||||
async function decide({ req, actor, requestId, status, note }) {
|
||||
if (!isAdmin(actor)) return { ok: false, status: 403, error: 'only an admin may decide a request' }
|
||||
if (!['approved', 'rejected'].includes(status)) {
|
||||
return { ok: false, status: 400, error: 'status must be approved or rejected' }
|
||||
}
|
||||
|
||||
const request = await moderationDb.findRequest(requestId)
|
||||
if (!request) return { ok: false, status: 404, error: 'request not found' }
|
||||
if (request.status !== 'pending') {
|
||||
return { ok: false, status: 409, error: `request is already ${request.status}` }
|
||||
}
|
||||
|
||||
const moved = await moderationDb.decideRequest(requestId, {
|
||||
status, decidedBy: actor.id, decidedUsername: actor.username, note,
|
||||
})
|
||||
if (!moved) return { ok: false, status: 409, error: 'request was decided by someone else' }
|
||||
|
||||
const team = await teamsDb.findById(request.team_id)
|
||||
if (status === 'approved' && team) {
|
||||
await applyAction({
|
||||
req,
|
||||
actor,
|
||||
team,
|
||||
action: request.action,
|
||||
payload: parsePayload(request.payload),
|
||||
reason: request.reason,
|
||||
})
|
||||
}
|
||||
|
||||
await activity.log({
|
||||
req,
|
||||
action: `team.moderation.${status}`,
|
||||
detail: `${actor.username} (#${actor.id}) ${status} request #${requestId} `
|
||||
+ `("${request.action}" on team #${request.team_id}, asked by ${request.requested_username || 'a deleted user'})`
|
||||
+ `${note ? `: "${note}"` : ''}`,
|
||||
})
|
||||
return { ok: true, applied: status === 'approved' }
|
||||
}
|
||||
|
||||
// The driver returns JSON columns already parsed on some versions and as a string
|
||||
// on others, so this normalises rather than assuming either.
|
||||
function parsePayload(payload) {
|
||||
if (payload == null) return {}
|
||||
if (typeof payload === 'object') return payload
|
||||
try {
|
||||
return JSON.parse(payload)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
screenForCreate,
|
||||
rescreen,
|
||||
requestOrApply,
|
||||
hide,
|
||||
decide,
|
||||
reviewQueue: moderationDb.reviewQueue,
|
||||
listRequests: moderationDb.listRequests,
|
||||
pendingForTeam: moderationDb.pendingForTeam,
|
||||
GATED_ACTIONS,
|
||||
}
|
||||
182
server/src/model/teams/teamProvider.js
Normal file
182
server/src/model/teams/teamProvider.js
Normal file
@@ -0,0 +1,182 @@
|
||||
// ── Calling the Team provider ──────────────────────────────────────────────
|
||||
//
|
||||
// The one place core asks a module a question and waits for the answer
|
||||
// (docs/website/TEAMS.md §2.3). Everything here exists to serve invariant 1:
|
||||
//
|
||||
// **Module unavailability is staleness, never emptiness.**
|
||||
//
|
||||
// No Team subsystem may apply a destructive result derived from a failed,
|
||||
// timed-out or unanswered module call. This file is where "failed" is defined, and
|
||||
// it is deliberately generous about what counts: a rejected promise, a timeout, a
|
||||
// non-object, a missing `ok`, or a structurally malformed row all leave with the
|
||||
// same `{ ok: false }` the module would have sent deliberately.
|
||||
//
|
||||
// **There is no shape a failure can take that core reads as "zero teams".** That
|
||||
// is the whole argument for the envelope, and the reason the provider signature is
|
||||
// not the obvious `getTeams(): Team[]` — a bare array has exactly one such shape,
|
||||
// `[]`, and it is the one a module returns while its sidecar is still connecting.
|
||||
//
|
||||
// Nothing here touches the database. It calls the module and hands back a value
|
||||
// the reconciler can trust the SHAPE of; whether to ACT on it is §2.4's question.
|
||||
|
||||
const registries = require('../../modules/registries')
|
||||
const log = require('../../utils/logger')('teams')
|
||||
|
||||
// The budget from §2.3. A provider is answering from its own cache or its own
|
||||
// sidecar client, both of which have their own timeouts well inside this; a call
|
||||
// that reaches ten seconds is wedged, not slow.
|
||||
const CALL_TIMEOUT_MS = 10_000
|
||||
|
||||
/** A uniform refusal. `reason` is for the operator, via team_sync_state. */
|
||||
const fail = (reason) => ({ ok: false, reason })
|
||||
|
||||
/**
|
||||
* Await `promise` with a timeout that cannot outlive the call.
|
||||
*
|
||||
* The timer is always cleared — including on the winning path — because an
|
||||
* uncleared 10s timer holds the event loop open, which in a test run means the
|
||||
* process hangs long after the assertions passed. The suite already learned this
|
||||
* one from a mariadb pool (test/_setup.js).
|
||||
*
|
||||
* It is also `unref`ed, which covers the case clearing cannot: when the module's
|
||||
* promise NEVER settles, the race stays pending and there is nothing to clear
|
||||
* until the deadline fires. An unreffed timer still fires normally while the
|
||||
* process is alive — the server's own listener is what keeps it alive — but it no
|
||||
* longer holds a shutdown open for ten seconds waiting on a module that is not
|
||||
* going to answer.
|
||||
*/
|
||||
function withTimeout(promise, ms) {
|
||||
let timer
|
||||
const timeout = new Promise((resolve) => {
|
||||
timer = setTimeout(() => resolve(fail(`provider did not answer within ${ms}ms`)), ms)
|
||||
if (typeof timer.unref === 'function') timer.unref()
|
||||
})
|
||||
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer))
|
||||
}
|
||||
|
||||
/**
|
||||
* Call one provider method and normalise whatever comes back into an envelope.
|
||||
*
|
||||
* `normalise` is only ever run on an `ok` answer, and may itself return a refusal
|
||||
* — a structurally malformed row is treated as a failed call rather than as data
|
||||
* to salvage. Salvaging is the dangerous option: dropping one unreadable member
|
||||
* from a roster is indistinguishable, downstream, from that member having left,
|
||||
* and the sync would mark them departed. Refusing costs one stale interval.
|
||||
*/
|
||||
async function call(method, normalise, ...args) {
|
||||
const provider = registries.registeredTeamProvider()
|
||||
if (!provider) return fail('no team provider is registered')
|
||||
|
||||
let answer
|
||||
try {
|
||||
answer = await withTimeout(Promise.resolve().then(() => provider[method](...args)), CALL_TIMEOUT_MS)
|
||||
} catch (err) {
|
||||
// A rejected promise is a module that threw, which is exactly as
|
||||
// unauthoritative as one that answered `{ ok: false }`.
|
||||
return fail(`${method}() threw: ${err.message}`)
|
||||
}
|
||||
|
||||
if (!answer || typeof answer !== 'object' || Array.isArray(answer)) {
|
||||
return fail(`${method}() returned ${Array.isArray(answer) ? 'an array' : typeof answer}, not an envelope`)
|
||||
}
|
||||
// `ok` must be present and true. A module that forgot the field is not one
|
||||
// asserting authority, and reading a missing field as truthy would put the
|
||||
// single most consequential decision in this file on a typo.
|
||||
if (answer.ok !== true) return fail(answer.reason || `${method}() answered not-ok`)
|
||||
|
||||
const normalised = normalise(answer)
|
||||
if (normalised.ok === false) {
|
||||
log.warn('team provider answered with a malformed payload', {
|
||||
owner: provider.owner, method, reason: normalised.reason,
|
||||
})
|
||||
}
|
||||
return normalised
|
||||
}
|
||||
|
||||
// `complete` defaults to TRUE when the module omits it, matching §2.3: the
|
||||
// envelope's optional field marks a partial answer, so its absence is the
|
||||
// ordinary authoritative case. A module that cannot enumerate exhaustively says
|
||||
// so explicitly.
|
||||
const isComplete = (answer) => answer.complete !== false
|
||||
|
||||
const str = (v) => (typeof v === 'string' ? v.trim() : '')
|
||||
|
||||
/** `{ ok, complete, teams: [{ externalId, name, abbr, meta }] }` */
|
||||
function normaliseTeams(answer) {
|
||||
if (!Array.isArray(answer.teams)) return fail('getTeams() answered ok with no teams array')
|
||||
const teams = []
|
||||
for (const raw of answer.teams) {
|
||||
const externalId = str(raw && raw.externalId)
|
||||
const name = str(raw && raw.name)
|
||||
// Both are load-bearing and neither has a safe default: externalId is the
|
||||
// identity the whole rename rule (§2.2) turns on, and a Team with no name has
|
||||
// no slug and no page.
|
||||
if (!externalId) return fail('a team in getTeams() has no externalId')
|
||||
if (!name) return fail(`team "${externalId}" has no name`)
|
||||
teams.push({
|
||||
externalId,
|
||||
name,
|
||||
abbr: str(raw.abbr) || null,
|
||||
// Opaque by contract (§10.5) — stored and handed back, never branched on.
|
||||
meta: raw.meta && typeof raw.meta === 'object' ? raw.meta : null,
|
||||
})
|
||||
}
|
||||
return { ok: true, complete: isComplete(answer), teams }
|
||||
}
|
||||
|
||||
/** `{ ok, complete, members: [{ memberKey, displayName, rankLabel, leader, online, userId }] }` */
|
||||
function normaliseMembers(answer) {
|
||||
if (!Array.isArray(answer.members)) return fail('getTeamMembers() answered ok with no members array')
|
||||
const members = []
|
||||
const seen = new Set()
|
||||
for (const raw of answer.members) {
|
||||
const memberKey = str(raw && raw.memberKey)
|
||||
if (!memberKey) return fail('a member has no memberKey')
|
||||
// A duplicate key would upsert twice and inflate no count but confuse every
|
||||
// reader; it also means the module's own identity rule is broken, which is
|
||||
// worth surfacing rather than quietly collapsing.
|
||||
if (seen.has(memberKey)) return fail(`member "${memberKey}" appears twice`)
|
||||
seen.add(memberKey)
|
||||
members.push({
|
||||
memberKey,
|
||||
displayName: str(raw.displayName) || null,
|
||||
rankLabel: str(raw.rankLabel) || null,
|
||||
leader: Boolean(raw.leader),
|
||||
online: Boolean(raw.online),
|
||||
// Resolved BY THE MODULE — it owns the game↔site link table (§2.3). Core
|
||||
// takes the number and never looks it up.
|
||||
userId: Number.isInteger(raw.userId) && raw.userId > 0 ? raw.userId : null,
|
||||
})
|
||||
}
|
||||
return { ok: true, complete: isComplete(answer), members }
|
||||
}
|
||||
|
||||
/** `{ ok, leaders: [memberKey] }` */
|
||||
function normaliseLeaders(answer) {
|
||||
if (!Array.isArray(answer.leaders)) return fail('getTeamLeaders() answered ok with no leaders array')
|
||||
const leaders = []
|
||||
for (const raw of answer.leaders) {
|
||||
const key = str(raw)
|
||||
if (!key) return fail('a leader entry is not a member key')
|
||||
if (!leaders.includes(key)) leaders.push(key)
|
||||
}
|
||||
return { ok: true, leaders }
|
||||
}
|
||||
|
||||
const getTeams = () => call('getTeams', normaliseTeams)
|
||||
const getTeamMembers = (externalId) => call('getTeamMembers', normaliseMembers, externalId)
|
||||
const getTeamLeaders = (externalId) => call('getTeamLeaders', normaliseLeaders, externalId)
|
||||
|
||||
/** Which module is authoritative, or null. The reconciler keys sync state on it. */
|
||||
const providerModuleId = () => {
|
||||
const provider = registries.registeredTeamProvider()
|
||||
return provider ? provider.owner : null
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getTeams,
|
||||
getTeamMembers,
|
||||
getTeamLeaders,
|
||||
providerModuleId,
|
||||
CALL_TIMEOUT_MS,
|
||||
}
|
||||
50
server/src/model/teams/teamSlug.js
Normal file
50
server/src/model/teams/teamSlug.js
Normal file
@@ -0,0 +1,50 @@
|
||||
// Deriving a Team's URL slug from a game-written name (TEAMS.md §2.1).
|
||||
//
|
||||
// A slug is derived ONCE, at create, and then frozen for the life of the row —
|
||||
// like `name`, and for the same reason: the Team page URL has to stay stable, and
|
||||
// a rename is an archive plus a create rather than an edit.
|
||||
|
||||
const MAX_SLUG = 180 // the column is 191; leaves room for a -NN suffix
|
||||
|
||||
/**
|
||||
* Reduce a name to a URL-safe stem.
|
||||
*
|
||||
* Diacritics are folded rather than stripped so "Ünderdark" becomes "underdark"
|
||||
* and not "nderdark". A name made entirely of characters that do not survive —
|
||||
* which a guild name genuinely can be, since the game accepts far more than a URL
|
||||
* does — leaves an empty stem, and the caller substitutes a stable fallback
|
||||
* rather than minting a Team with no address.
|
||||
*/
|
||||
function slugify(name) {
|
||||
return String(name || '')
|
||||
.normalize('NFKD')
|
||||
.replace(/[̀-ͯ]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, MAX_SLUG)
|
||||
.replace(/-+$/g, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* A slug not already taken, given the ones that are.
|
||||
*
|
||||
* `taken` must include ARCHIVED teams' slugs, not only active ones. The unique
|
||||
* key constrains active rows alone, so the database would allow a new Team to
|
||||
* take a retired Team's slug — and §2.2 promises the retired one stays readable
|
||||
* at that address, which is what a bookmark or an old Discord link resolves to.
|
||||
*/
|
||||
function uniqueSlug(name, taken, { fallback = 'team' } = {}) {
|
||||
const base = slugify(name) || fallback
|
||||
const used = new Set(taken)
|
||||
if (!used.has(base)) return base
|
||||
// Bounded rather than unbounded: a suffix search that cannot terminate is worse
|
||||
// than a slug with an id in it, and 999 same-named teams is already absurd.
|
||||
for (let n = 2; n <= 999; n++) {
|
||||
const candidate = `${base}-${n}`
|
||||
if (!used.has(candidate)) return candidate
|
||||
}
|
||||
return `${base}-${Date.now().toString(36)}`
|
||||
}
|
||||
|
||||
module.exports = { slugify, uniqueSlug, MAX_SLUG }
|
||||
497
server/src/model/teams/teamSync.model.js
Normal file
497
server/src/model/teams/teamSync.model.js
Normal file
@@ -0,0 +1,497 @@
|
||||
// ── The reconciler ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// Core's projection of the module's Teams, kept in step (TEAMS.md §2.4). This is
|
||||
// the only thing that writes `team_members`, and one of only two things that
|
||||
// write `teams.status`.
|
||||
//
|
||||
// **The four places it refuses to act** are the point of the file, and they are
|
||||
// all one rule stated four ways: *a result derived from an answer core does not
|
||||
// trust is never applied.* Anything less specific tends to collapse, under
|
||||
// maintenance, into "a failed call means no teams" — which is invariant 1's
|
||||
// failure mode and would empty every roster on the site the first time a sidecar
|
||||
// restarted.
|
||||
//
|
||||
// 1. `getTeams()` not ok → write sync state, touch NOTHING, return.
|
||||
// 2. ok but empty, core holds ≥1 → quarantine; apply only if the NEXT
|
||||
// authoritative answer agrees.
|
||||
// 3. `getTeamMembers()` not ok → that Team's roster untouched and stale;
|
||||
// the other Teams carry on.
|
||||
// 4. ok but zero members, had some → the same two-strikes quarantine, per Team.
|
||||
//
|
||||
// Gates 2 and 4 exist because an authoritative-looking empty answer during a cold
|
||||
// start is the one failure indistinguishable from a real wipe. "Every Team on the
|
||||
// shard disbanded at once" costs one interval of delay to confirm; getting it
|
||||
// wrong costs every roster on the site.
|
||||
//
|
||||
// Events (§2.3) are an OPTIMISATION, never the source of truth. They make the
|
||||
// common case immediate; reconciliation is what makes it correct. Nothing
|
||||
// destructive at Team level is ever driven by one — §2.2 scopes archival to an
|
||||
// authoritative full list, so a `team.disbanded` event schedules a run rather
|
||||
// than archiving, and a spurious event costs a reconcile instead of a Team.
|
||||
|
||||
const teamsDb = require('./teams.db')
|
||||
const teamProvider = require('./teamProvider')
|
||||
const moderation = require('./teamModeration.model')
|
||||
const { slugify, uniqueSlug } = require('./teamSlug')
|
||||
const settings = require('../settings/settings.model')
|
||||
const log = require('../../utils/logger')('teams')
|
||||
|
||||
// At most one run per 30s (§2.4), so a sidecar flapping cannot become a
|
||||
// reconciliation storm — every flap publishes events, and every event asks for a
|
||||
// run.
|
||||
const DEBOUNCE_MS = 30_000
|
||||
const DEFAULT_INTERVAL_S = 900
|
||||
const MIN_INTERVAL_S = 60
|
||||
const INTERVAL_KEY = 'teams_reconcile_interval_s'
|
||||
|
||||
// The six kinds a module may publish (§2.3). Six rather than the four a
|
||||
// membership-shaped reading suggests, because leadership is its own authority
|
||||
// path and a leadership change must be expressible without pretending someone
|
||||
// joined or left.
|
||||
const EVENT_KINDS = new Set([
|
||||
'team.created', 'team.disbanded',
|
||||
'team.member.added', 'team.member.removed',
|
||||
'team.leader.added', 'team.leader.removed',
|
||||
])
|
||||
|
||||
// Kinds that can only be answered by a full list. `team.created` cannot be
|
||||
// applied from a delta — a Team built from one has no name, no roster and no
|
||||
// leaders — and `team.disbanded` must not be, per §2.2.
|
||||
const RECONCILE_ONLY = new Set(['team.created', 'team.disbanded'])
|
||||
|
||||
// ── Scheduling state (in-process; one provider per deployment) ─────────────
|
||||
|
||||
let running = false
|
||||
let rerunReason = null
|
||||
let lastRunAt = 0
|
||||
let debounceTimer = null
|
||||
let pollTimer = null
|
||||
let started = false
|
||||
|
||||
/** Resolve the poll interval, floored so a bad setting cannot become a hot loop. */
|
||||
async function intervalSeconds() {
|
||||
let raw
|
||||
try {
|
||||
raw = await settings.get(INTERVAL_KEY)
|
||||
} catch {
|
||||
return DEFAULT_INTERVAL_S
|
||||
}
|
||||
const n = Number.parseInt(raw, 10)
|
||||
if (!Number.isFinite(n) || n < MIN_INTERVAL_S) return DEFAULT_INTERVAL_S
|
||||
return n
|
||||
}
|
||||
|
||||
/**
|
||||
* Backoff, capped at the poll interval (§2.4).
|
||||
*
|
||||
* The cap is what keeps this a backoff rather than an outage: a module down for a
|
||||
* day would otherwise reach a delay measured in weeks and stay stale long after
|
||||
* it recovered.
|
||||
*/
|
||||
function backoffSeconds(consecutiveFailures, intervalS) {
|
||||
if (!consecutiveFailures) return intervalS
|
||||
return Math.min(intervalS, 2 ** Math.min(consecutiveFailures, 16) * 15)
|
||||
}
|
||||
|
||||
// ── Applying one Team ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create the row for a Team core has not seen, deriving its slug and screening
|
||||
* its name against the reserved list (§2.8).
|
||||
*
|
||||
* The row is created whatever the screening says, and hidden if it matched. Core
|
||||
* cannot refuse a name: the guild already exists in the game and core is a mirror
|
||||
* of it, not an authority over it. A hidden Team is absent from public surfaces
|
||||
* and completely functional for its own members — the people in it are not being
|
||||
* punished for a name their leader chose.
|
||||
*/
|
||||
async function createTeam(moduleId, team) {
|
||||
const taken = await teamsDb.slugsLike(slugify(team.name) || 'team')
|
||||
const slug = uniqueSlug(team.name, taken)
|
||||
const screened = await moderation.screenForCreate(team.name)
|
||||
const id = await teamsDb.insertTeam({ moduleId, slug, ...team, ...screened })
|
||||
log.info('team created', {
|
||||
moduleId, externalId: team.externalId, name: team.name, slug, hidden: Boolean(screened.hidden),
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* The §2.2 rename rule: same id and a different name is an archive plus a create.
|
||||
*
|
||||
* The old row keeps its forum, its activity and its grants, all read-only, and
|
||||
* points at its successor so the old slug can explain itself instead of 404ing.
|
||||
* Core never decides whether this is "really" the same team — that judgement is
|
||||
* the module's, expressed in whether it reuses the external id (§10.5).
|
||||
*/
|
||||
async function applyRename(moduleId, existing, team) {
|
||||
const successorId = await createTeam(moduleId, team)
|
||||
await teamsDb.archiveTeam(existing.id, 'renamed', successorId)
|
||||
log.info('team renamed; previous row archived', {
|
||||
externalId: team.externalId, from: existing.name, to: team.name, archivedId: existing.id, successorId,
|
||||
})
|
||||
return successorId
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync one Team's roster and leadership. Gates 3 and 4 live here.
|
||||
*
|
||||
* Returns whether the roster was applied, so the caller can tell "synced" from
|
||||
* "left alone", which is the difference between fresh and stale on that Team's
|
||||
* page.
|
||||
*/
|
||||
async function syncRoster(team) {
|
||||
const answer = await teamProvider.getTeamMembers(team.external_id)
|
||||
|
||||
// Gate 3. One Team's unanswerable roster is not the other Teams' problem, and
|
||||
// it is certainly not an empty roster.
|
||||
if (!answer.ok) {
|
||||
log.warn('roster left untouched; provider could not answer', {
|
||||
externalId: team.external_id, reason: answer.reason,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
const known = await teamsDb.memberKeys(team.id)
|
||||
|
||||
// Gate 4, the per-Team twin of gate 2.
|
||||
if (answer.complete && answer.members.length === 0 && known.length > 0) {
|
||||
if (!team.members_empty_since) {
|
||||
await teamsDb.setMembersEmptySince(team.id, new Date())
|
||||
log.warn('empty roster quarantined; awaiting a second answer', {
|
||||
externalId: team.external_id, had: known.length,
|
||||
})
|
||||
return false
|
||||
}
|
||||
log.warn('empty roster confirmed by a second answer; departing every member', {
|
||||
externalId: team.external_id, had: known.length,
|
||||
})
|
||||
} else if (team.members_empty_since) {
|
||||
// Any non-empty answer clears the quarantine.
|
||||
await teamsDb.setMembersEmptySince(team.id, null)
|
||||
}
|
||||
|
||||
for (const member of answer.members) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await teamsDb.upsertMember({
|
||||
teamId: team.id,
|
||||
memberKey: member.memberKey,
|
||||
displayName: member.displayName,
|
||||
userId: member.userId,
|
||||
isLeader: member.leader,
|
||||
rankLabel: member.rankLabel,
|
||||
online: member.online,
|
||||
})
|
||||
}
|
||||
|
||||
// Removals only from a COMPLETE answer. `complete: false` means "valid but
|
||||
// partial", so additions and updates apply and nothing is taken away.
|
||||
if (answer.complete) {
|
||||
const seen = new Set(answer.members.map((m) => m.memberKey))
|
||||
await teamsDb.markDeparted(team.id, known.filter((key) => !seen.has(key)))
|
||||
}
|
||||
|
||||
// Leadership is a separate question with a separate answer, and a provider that
|
||||
// cannot answer it leaves the synced value alone rather than demoting everyone.
|
||||
const leaders = await teamProvider.getTeamLeaders(team.external_id)
|
||||
if (leaders.ok) {
|
||||
await teamsDb.setLeaders(team.id, leaders.leaders)
|
||||
} else {
|
||||
log.warn('leadership left untouched; provider could not answer', {
|
||||
externalId: team.external_id, reason: leaders.reason,
|
||||
})
|
||||
}
|
||||
|
||||
await teamsDb.recount(team.id)
|
||||
await teamsDb.markRosterSynced(team.id)
|
||||
return true
|
||||
}
|
||||
|
||||
// ── The run ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* One full reconciliation. Callers use `request()`; this is the body it guards.
|
||||
*
|
||||
* Never throws: a reconcile is a background job, and a rejection here would
|
||||
* surface as an unhandled rejection in the poll timer rather than as anything an
|
||||
* operator could act on. The failure is recorded where it can be read — in
|
||||
* `team_sync_state`, which Admin → Teams shows verbatim.
|
||||
*/
|
||||
async function runOnce(reason) {
|
||||
const moduleId = teamProvider.providerModuleId()
|
||||
if (!moduleId) return { ok: false, reason: 'no team provider is registered' }
|
||||
|
||||
await teamsDb.recordAttempt(moduleId)
|
||||
const answer = await teamProvider.getTeams()
|
||||
|
||||
// Gate 1.
|
||||
if (!answer.ok) {
|
||||
await teamsDb.recordFailure(moduleId, answer.reason)
|
||||
log.warn('reconcile refused; provider could not answer', { reason: answer.reason, trigger: reason })
|
||||
return { ok: false, reason: answer.reason }
|
||||
}
|
||||
|
||||
const existing = await teamsDb.activeByModule(moduleId)
|
||||
|
||||
// Gate 2. Only a COMPLETE answer can mean "there are no teams" — a partial one
|
||||
// removes nothing by definition.
|
||||
if (answer.complete && answer.teams.length === 0 && existing.length > 0) {
|
||||
const state = await teamsDb.syncState(moduleId)
|
||||
if (!state || !state.pending_empty_since) {
|
||||
await teamsDb.setPendingEmpty(moduleId, new Date())
|
||||
await teamsDb.recordSuccess(moduleId)
|
||||
log.warn('empty team list quarantined; awaiting a second answer', { held: existing.length })
|
||||
return { ok: true, quarantined: true, applied: 0 }
|
||||
}
|
||||
const intervalS = await intervalSeconds()
|
||||
const waited = (Date.now() - new Date(state.pending_empty_since).getTime()) / 1000
|
||||
if (waited < intervalS) {
|
||||
await teamsDb.recordSuccess(moduleId)
|
||||
log.warn('empty team list still quarantined', { waitedSeconds: Math.round(waited), intervalS })
|
||||
return { ok: true, quarantined: true, applied: 0 }
|
||||
}
|
||||
log.warn('empty team list confirmed; archiving every active team', { count: existing.length })
|
||||
} else if (answer.teams.length) {
|
||||
// Any non-empty answer clears the quarantine.
|
||||
await teamsDb.setPendingEmpty(moduleId, null)
|
||||
}
|
||||
|
||||
const byExternalId = new Map(existing.map((t) => [t.external_id, t]))
|
||||
const seen = new Set()
|
||||
let created = 0
|
||||
let renamed = 0
|
||||
let rosters = 0
|
||||
|
||||
for (const team of answer.teams) {
|
||||
seen.add(team.externalId)
|
||||
const current = byExternalId.get(team.externalId)
|
||||
let id
|
||||
if (!current) {
|
||||
id = await createTeam(moduleId, team)
|
||||
created += 1
|
||||
} else if (current.name !== team.name) {
|
||||
id = await applyRename(moduleId, current, team)
|
||||
renamed += 1
|
||||
} else {
|
||||
id = current.id
|
||||
await teamsDb.updateTeam(id, { abbr: team.abbr, meta: team.meta })
|
||||
}
|
||||
|
||||
// Re-read rather than reusing `current`: a create or a rename has just made a
|
||||
// row this loop has never seen, and syncRoster reads the quarantine stamp off
|
||||
// it. Passing a stale object would drop the second strike of gate 4.
|
||||
const row = await teamsDb.findById(id)
|
||||
if (row && await syncRoster(row)) rosters += 1
|
||||
}
|
||||
|
||||
// Archive what the module no longer lists — the §2.2 disband path, and the only
|
||||
// one. Guarded by `complete` for the same reason removals are.
|
||||
let archived = 0
|
||||
if (answer.complete) {
|
||||
for (const team of existing) {
|
||||
if (seen.has(team.external_id)) continue
|
||||
await teamsDb.archiveTeam(team.id, 'disbanded')
|
||||
archived += 1
|
||||
log.info('team archived; absent from an authoritative list', {
|
||||
externalId: team.external_id, name: team.name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Re-screen the names no human has ruled on. Names are immutable per row, so
|
||||
// this only changes an outcome when the reserved TERMS changed — an operator
|
||||
// adding one, or the deployment being renamed — which is exactly the case a
|
||||
// create-time-only check would miss forever.
|
||||
const rehidden = await moderation.rescreen(moduleId)
|
||||
|
||||
await teamsDb.recordSuccess(moduleId)
|
||||
log.info('reconcile complete', {
|
||||
trigger: reason, created, renamed, archived, rosters, rehidden, total: answer.teams.length,
|
||||
})
|
||||
return { ok: true, created, renamed, archived, rosters, rehidden }
|
||||
}
|
||||
|
||||
// ── The public entry points ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Run now, awaited, with the per-module lock held. Admin → Resync uses this,
|
||||
* because an operator pressing a button is owed the outcome rather than a
|
||||
* promise that something will happen soon.
|
||||
*
|
||||
* A run already in progress is JOINED rather than queued: the caller wants "the
|
||||
* projection is now current", and a run that started a moment ago delivers that.
|
||||
*/
|
||||
async function reconcileNow(reason = 'manual') {
|
||||
if (running) {
|
||||
rerunReason = reason
|
||||
return { ok: true, joined: true }
|
||||
}
|
||||
running = true
|
||||
try {
|
||||
const result = await runOnce(reason)
|
||||
lastRunAt = Date.now()
|
||||
return result
|
||||
} catch (err) {
|
||||
log.error('reconcile threw', { message: err.message, trigger: reason })
|
||||
return { ok: false, reason: err.message }
|
||||
} finally {
|
||||
running = false
|
||||
const queued = rerunReason
|
||||
rerunReason = null
|
||||
// Something asked while this run was in flight, so it saw state this run may
|
||||
// have been too early to include. Ask again, through the debounce.
|
||||
if (queued) request({ reason: queued })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask for a reconciliation. Returns immediately and never rejects — this is what
|
||||
* `ctx.teams.reconcile()` is (§2.3), and a module must not be able to make its
|
||||
* own call site slow or its own errors someone else's.
|
||||
*/
|
||||
function request({ reason = 'module' } = {}) {
|
||||
if (debounceTimer) return
|
||||
const since = Date.now() - lastRunAt
|
||||
if (running) {
|
||||
rerunReason = reason
|
||||
return
|
||||
}
|
||||
if (since >= DEBOUNCE_MS) {
|
||||
reconcileNow(reason).catch(() => {})
|
||||
return
|
||||
}
|
||||
debounceTimer = setTimeout(() => {
|
||||
debounceTimer = null
|
||||
reconcileNow(reason).catch(() => {})
|
||||
}, DEBOUNCE_MS - since)
|
||||
// Unreffed for the same reason the provider's deadline is: a pending debounce
|
||||
// must not hold a shutdown open waiting to do background work.
|
||||
if (typeof debounceTimer.unref === 'function') debounceTimer.unref()
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a module-published event (§2.3).
|
||||
*
|
||||
* Deltas are applied only for a Team core already knows, and only for the four
|
||||
* kinds a delta can express. Everything else — an unknown Team, a create, a
|
||||
* disband — asks for a reconciliation instead, because a Team invented from a
|
||||
* delta has no name, no roster and no leaders, and an archive driven by one is
|
||||
* destruction on the strength of a message that may simply have been repeated.
|
||||
*/
|
||||
async function publish(event) {
|
||||
const { kind, externalId } = event || {}
|
||||
if (!EVENT_KINDS.has(kind)) throw new Error(`teams.publish: unknown event kind "${kind}"`)
|
||||
const id = typeof externalId === 'string' ? externalId.trim() : ''
|
||||
if (!id) throw new Error(`teams.publish: ${kind} has no externalId`)
|
||||
|
||||
const moduleId = teamProvider.providerModuleId()
|
||||
if (!moduleId) return
|
||||
|
||||
if (RECONCILE_ONLY.has(kind)) {
|
||||
request({ reason: kind })
|
||||
return
|
||||
}
|
||||
|
||||
const team = await teamsDb.findActive(moduleId, id)
|
||||
if (!team) {
|
||||
request({ reason: `${kind} for an unknown team` })
|
||||
return
|
||||
}
|
||||
|
||||
const memberKey = typeof event.memberKey === 'string' ? event.memberKey.trim() : ''
|
||||
if (!memberKey) throw new Error(`teams.publish: ${kind} has no memberKey`)
|
||||
|
||||
switch (kind) {
|
||||
case 'team.member.added':
|
||||
await teamsDb.upsertMember({
|
||||
teamId: team.id,
|
||||
memberKey,
|
||||
displayName: typeof event.displayName === 'string' ? event.displayName.trim() : null,
|
||||
userId: Number.isInteger(event.userId) && event.userId > 0 ? event.userId : null,
|
||||
isLeader: Boolean(event.leader),
|
||||
rankLabel: typeof event.rankLabel === 'string' ? event.rankLabel.trim() : null,
|
||||
online: Boolean(event.online),
|
||||
})
|
||||
break
|
||||
case 'team.member.removed':
|
||||
await teamsDb.markDeparted(team.id, [memberKey])
|
||||
break
|
||||
case 'team.leader.added':
|
||||
case 'team.leader.removed':
|
||||
// A no-op when the member is unknown: the row is created by the roster, not
|
||||
// by a leadership delta, and inventing one here would put a member on the
|
||||
// roster whose only evidence is that someone promoted them.
|
||||
await teamsDb.setMemberLeader(team.id, memberKey, kind === 'team.leader.added')
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
await teamsDb.recount(team.id)
|
||||
// A delta is a hint that something changed, not a claim to have applied all of
|
||||
// it, so every one still asks for the run that makes it correct.
|
||||
request({ reason: kind })
|
||||
}
|
||||
|
||||
// ── The poll ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function scheduleNextPoll() {
|
||||
const intervalS = await intervalSeconds()
|
||||
const moduleId = teamProvider.providerModuleId()
|
||||
let delayS = intervalS
|
||||
if (moduleId) {
|
||||
const state = await teamsDb.syncState(moduleId).catch(() => null)
|
||||
if (state) delayS = backoffSeconds(state.consecutive_failures, intervalS)
|
||||
}
|
||||
pollTimer = setTimeout(() => {
|
||||
reconcileNow('poll').catch(() => {}).then(() => { if (started) scheduleNextPoll().catch(() => {}) })
|
||||
}, delayS * 1000)
|
||||
if (typeof pollTimer.unref === 'function') pollTimer.unref()
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the boot reconcile and the poll. Called from the module lifecycle, after
|
||||
* every module has started — the website may have been down across a whole guild
|
||||
* war, so the first thing it does on the way up is ask.
|
||||
*/
|
||||
async function start() {
|
||||
if (started) return
|
||||
started = true
|
||||
if (!teamProvider.providerModuleId()) {
|
||||
log.info('no team provider registered; the reconciler stays idle')
|
||||
return
|
||||
}
|
||||
await reconcileNow('boot')
|
||||
await scheduleNextPoll()
|
||||
}
|
||||
|
||||
function stop() {
|
||||
started = false
|
||||
if (pollTimer) clearTimeout(pollTimer)
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
pollTimer = null
|
||||
debounceTimer = null
|
||||
}
|
||||
|
||||
// Test-only: the scheduler is module-level state, so a test that triggers a run
|
||||
// has to be able to put it back.
|
||||
function _reset() {
|
||||
stop()
|
||||
running = false
|
||||
rerunReason = null
|
||||
lastRunAt = 0
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
reconcileNow,
|
||||
request,
|
||||
publish,
|
||||
start,
|
||||
stop,
|
||||
intervalSeconds,
|
||||
backoffSeconds,
|
||||
EVENT_KINDS,
|
||||
DEBOUNCE_MS,
|
||||
DEFAULT_INTERVAL_S,
|
||||
_reset,
|
||||
}
|
||||
312
server/src/model/teams/teams.db.js
Normal file
312
server/src/model/teams/teams.db.js
Normal file
@@ -0,0 +1,312 @@
|
||||
// SQL for the Team tables. Raw parameterised mariadb, no ORM, per the layered
|
||||
// backend convention (router → controller → model → db).
|
||||
//
|
||||
// This file holds statements only. Every decision about WHETHER to write — the
|
||||
// four refusal gates, the quarantine, the rename rule — lives in the models above
|
||||
// it, because a gate expressed as a WHERE clause is a gate nobody can find.
|
||||
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
// ── teams ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const TEAM_COLUMNS = `
|
||||
id, module_id, external_id, name, abbr, slug, status, meta,
|
||||
member_count, linked_count, online_count,
|
||||
hidden, hidden_reason, hidden_term, name_reviewed_at, display_name_override,
|
||||
roster_synced_at, members_empty_since,
|
||||
succeeded_by, created_at, archived_at, archived_reason`
|
||||
|
||||
/** Every ACTIVE team for a module — the set the reconciler diffs against. */
|
||||
async function activeByModule(moduleId) {
|
||||
return query(
|
||||
`SELECT ${TEAM_COLUMNS} FROM teams WHERE module_id = ? AND status = 'active' ORDER BY id`,
|
||||
[moduleId],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every ACTIVE team, whichever module owns it.
|
||||
*
|
||||
* For the READ side, which must not be keyed on a provider being registered. The
|
||||
* rows are core's and they outlive the module that filled them — a module
|
||||
* uninstalled or disabled leaves a projection that is unmaintained, not one that
|
||||
* stopped existing. Listing by provider made `/teams` empty while
|
||||
* `/teams/:slug/members` still answered in full, since the lookup goes by slug:
|
||||
* the index denied a Team that direct URLs served.
|
||||
*/
|
||||
async function allActive() {
|
||||
return query(`SELECT ${TEAM_COLUMNS} FROM teams WHERE status = 'active' ORDER BY id`)
|
||||
}
|
||||
|
||||
/** The ACTIVE row for an external id, or undefined. At most one, by uq_teams_active. */
|
||||
async function findActive(moduleId, externalId) {
|
||||
const rows = await query(
|
||||
`SELECT ${TEAM_COLUMNS} FROM teams WHERE module_id = ? AND external_id = ? AND status = 'active'`,
|
||||
[moduleId, externalId],
|
||||
)
|
||||
return rows[0]
|
||||
}
|
||||
|
||||
async function findById(id) {
|
||||
const rows = await query(`SELECT ${TEAM_COLUMNS} FROM teams WHERE id = ?`, [id])
|
||||
return rows[0]
|
||||
}
|
||||
|
||||
/** By slug, ACTIVE or ARCHIVED — an archived Team stays reachable at its old slug (§2.2). */
|
||||
async function findBySlug(slug) {
|
||||
const rows = await query(
|
||||
`SELECT ${TEAM_COLUMNS} FROM teams WHERE slug = ? ORDER BY (status = 'active') DESC, id DESC LIMIT 1`,
|
||||
[slug],
|
||||
)
|
||||
return rows[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Slugs already taken, ACTIVE OR ARCHIVED.
|
||||
*
|
||||
* The unique key only constrains active rows, and this deliberately checks more
|
||||
* than the key does: §2.2 promises an archived Team stays readable at its old
|
||||
* slug, and handing that slug to a new Team would silently break every bookmark
|
||||
* and Discord link pointing at the old one.
|
||||
*/
|
||||
async function slugsLike(base) {
|
||||
const rows = await query('SELECT slug FROM teams WHERE slug = ? OR slug LIKE ?', [base, `${base}-%`])
|
||||
return rows.map((r) => r.slug)
|
||||
}
|
||||
|
||||
async function insertTeam({ moduleId, externalId, name, abbr, slug, meta, hidden, hiddenReason, hiddenTerm }) {
|
||||
const res = await query(
|
||||
`INSERT INTO teams (module_id, external_id, name, abbr, slug, meta, hidden, hidden_reason, hidden_term)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[moduleId, externalId, name, abbr, slug, meta == null ? null : JSON.stringify(meta),
|
||||
hidden ? 1 : 0, hiddenReason || null, hiddenTerm || null],
|
||||
)
|
||||
return res.insertId
|
||||
}
|
||||
|
||||
/** Update the mutable fields. `name` and `slug` are absent by design — §2.2 freezes both. */
|
||||
async function updateTeam(id, { abbr, meta }) {
|
||||
await query('UPDATE teams SET abbr = ?, meta = ? WHERE id = ?',
|
||||
[abbr, meta == null ? null : JSON.stringify(meta), id])
|
||||
}
|
||||
|
||||
async function archiveTeam(id, reason, succeededBy = null) {
|
||||
await query(
|
||||
`UPDATE teams SET status = 'archived', archived_at = NOW(), archived_reason = ?, succeeded_by = ?
|
||||
WHERE id = ? AND status = 'active'`,
|
||||
[reason, succeededBy, id],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute the three denormalised counts from the projection.
|
||||
*
|
||||
* Derived in one statement rather than incremented as rows change, so a missed
|
||||
* delta can never leave a count drifting from the table it summarises — the count
|
||||
* is only ever as wrong as the projection is.
|
||||
*/
|
||||
async function recount(teamId) {
|
||||
await query(
|
||||
`UPDATE teams t SET
|
||||
member_count = (SELECT COUNT(*) FROM team_members m WHERE m.team_id = t.id AND m.status = 'active'),
|
||||
linked_count = (SELECT COUNT(*) FROM team_members m WHERE m.team_id = t.id AND m.status = 'active' AND m.user_id IS NOT NULL),
|
||||
online_count = (SELECT COUNT(*) FROM team_members m WHERE m.team_id = t.id AND m.status = 'active' AND m.online = 1)
|
||||
WHERE t.id = ?`,
|
||||
[teamId],
|
||||
)
|
||||
}
|
||||
|
||||
// ── team_members ───────────────────────────────────────────────────────────
|
||||
|
||||
const MEMBER_COLUMNS = `
|
||||
team_id, member_key, display_name, user_id, is_leader, rank_label, online, status,
|
||||
first_seen_at, last_seen_at, departed_at`
|
||||
|
||||
async function membersByTeam(teamId, { includeDeparted = false } = {}) {
|
||||
return query(
|
||||
`SELECT ${MEMBER_COLUMNS} FROM team_members WHERE team_id = ?` +
|
||||
(includeDeparted ? '' : " AND status = 'active'") +
|
||||
' ORDER BY is_leader DESC, display_name, member_key',
|
||||
[teamId],
|
||||
)
|
||||
}
|
||||
|
||||
async function memberKeys(teamId) {
|
||||
const rows = await query("SELECT member_key FROM team_members WHERE team_id = ? AND status = 'active'", [teamId])
|
||||
return rows.map((r) => r.member_key)
|
||||
}
|
||||
|
||||
async function findMember(teamId, memberKey) {
|
||||
const rows = await query(`SELECT ${MEMBER_COLUMNS} FROM team_members WHERE team_id = ? AND member_key = ?`,
|
||||
[teamId, memberKey])
|
||||
return rows[0]
|
||||
}
|
||||
|
||||
/** The caller's ACTIVE membership of a team, or undefined. Path 1 of §2.5, and only path 1. */
|
||||
async function activeByUser(teamId, userId) {
|
||||
const rows = await query(
|
||||
`SELECT ${MEMBER_COLUMNS} FROM team_members WHERE team_id = ? AND user_id = ? AND status = 'active'`,
|
||||
[teamId, userId],
|
||||
)
|
||||
return rows[0]
|
||||
}
|
||||
|
||||
/** Every ACTIVE membership a user holds, with the team joined on. */
|
||||
async function activeTeamsForUser(userId) {
|
||||
return query(
|
||||
`SELECT ${TEAM_COLUMNS.split(',').map((c) => `t.${c.trim()}`).join(', ')},
|
||||
m.member_key, m.is_leader, m.rank_label, m.display_name AS member_display_name
|
||||
FROM team_members m JOIN teams t ON t.id = m.team_id
|
||||
WHERE m.user_id = ? AND m.status = 'active' AND t.status = 'active'
|
||||
ORDER BY t.name`,
|
||||
[userId],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert or refresh one member row.
|
||||
*
|
||||
* `first_seen_at` is never overwritten, so a member who leaves and rejoins keeps
|
||||
* the date they first appeared; `status` returns to active on the same statement,
|
||||
* which is what makes a rejoin a revived row rather than a second one.
|
||||
*
|
||||
* **`is_leader` is set on INSERT only, and deliberately not on update.** Path 2 of
|
||||
* §2.5 is answered by `getTeamLeaders()`, not by the roster — two writers for one
|
||||
* column is how a refused leadership answer turns into a silent demotion, because
|
||||
* the roster would already have written `leader: false` before the authoritative
|
||||
* call was even made. Seeding it on insert means a Team whose leadership call is
|
||||
* failing is not leaderless from the start; after that, only setLeaders() moves it.
|
||||
*/
|
||||
async function upsertMember({ teamId, memberKey, displayName, userId, isLeader, rankLabel, online }) {
|
||||
await query(
|
||||
`INSERT INTO team_members (team_id, member_key, display_name, user_id, is_leader, rank_label, online)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
display_name = VALUES(display_name),
|
||||
user_id = VALUES(user_id),
|
||||
rank_label = VALUES(rank_label),
|
||||
online = VALUES(online),
|
||||
status = 'active',
|
||||
departed_at = NULL,
|
||||
last_seen_at = NOW()`,
|
||||
[teamId, memberKey, displayName, userId, isLeader ? 1 : 0, rankLabel, online ? 1 : 0],
|
||||
)
|
||||
}
|
||||
|
||||
/** Soft-depart the named members. Rows are kept so history and rejoins survive. */
|
||||
async function markDeparted(teamId, memberKeys_) {
|
||||
if (!memberKeys_.length) return
|
||||
const holes = memberKeys_.map(() => '?').join(', ')
|
||||
await query(
|
||||
`UPDATE team_members SET status = 'departed', departed_at = NOW(), online = 0
|
||||
WHERE team_id = ? AND status = 'active' AND member_key IN (${holes})`,
|
||||
[teamId, ...memberKeys_],
|
||||
)
|
||||
}
|
||||
|
||||
/** Set is_leader for a whole team in one pass — the sync's path-2 write. */
|
||||
async function setLeaders(teamId, leaderKeys) {
|
||||
if (leaderKeys.length) {
|
||||
const holes = leaderKeys.map(() => '?').join(', ')
|
||||
await query(
|
||||
`UPDATE team_members SET is_leader = (member_key IN (${holes})) WHERE team_id = ?`,
|
||||
[...leaderKeys, teamId],
|
||||
)
|
||||
} else {
|
||||
await query('UPDATE team_members SET is_leader = 0 WHERE team_id = ?', [teamId])
|
||||
}
|
||||
}
|
||||
|
||||
async function setMemberLeader(teamId, memberKey, isLeader) {
|
||||
await query('UPDATE team_members SET is_leader = ? WHERE team_id = ? AND member_key = ?',
|
||||
[isLeader ? 1 : 0, teamId, memberKey])
|
||||
}
|
||||
|
||||
// ── team_sync_state ────────────────────────────────────────────────────────
|
||||
|
||||
async function syncState(moduleId) {
|
||||
const rows = await query(
|
||||
`SELECT module_id, last_attempt_at, last_success_at, consecutive_failures, last_error, pending_empty_since
|
||||
FROM team_sync_state WHERE module_id = ?`,
|
||||
[moduleId],
|
||||
)
|
||||
return rows[0]
|
||||
}
|
||||
|
||||
async function recordAttempt(moduleId) {
|
||||
await query(
|
||||
`INSERT INTO team_sync_state (module_id, last_attempt_at) VALUES (?, NOW())
|
||||
ON DUPLICATE KEY UPDATE last_attempt_at = NOW()`,
|
||||
[moduleId],
|
||||
)
|
||||
}
|
||||
|
||||
async function recordFailure(moduleId, error) {
|
||||
await query(
|
||||
`INSERT INTO team_sync_state (module_id, last_attempt_at, consecutive_failures, last_error)
|
||||
VALUES (?, NOW(), 1, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
last_attempt_at = NOW(),
|
||||
consecutive_failures = consecutive_failures + 1,
|
||||
last_error = VALUES(last_error)`,
|
||||
[moduleId, String(error || '').slice(0, 500)],
|
||||
)
|
||||
}
|
||||
|
||||
async function recordSuccess(moduleId) {
|
||||
await query(
|
||||
`INSERT INTO team_sync_state (module_id, last_attempt_at, last_success_at, consecutive_failures, last_error)
|
||||
VALUES (?, NOW(), NOW(), 0, NULL)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
last_attempt_at = NOW(), last_success_at = NOW(), consecutive_failures = 0, last_error = NULL`,
|
||||
[moduleId],
|
||||
)
|
||||
}
|
||||
|
||||
/** Bumped only when a roster was actually APPLIED — never on a refused call. */
|
||||
async function markRosterSynced(teamId) {
|
||||
await query('UPDATE teams SET roster_synced_at = NOW() WHERE id = ?', [teamId])
|
||||
}
|
||||
|
||||
/** §2.4 gate 4's per-Team quarantine. `since = null` clears it. */
|
||||
async function setMembersEmptySince(teamId, since) {
|
||||
await query('UPDATE teams SET members_empty_since = ? WHERE id = ?', [since, teamId])
|
||||
}
|
||||
|
||||
/** The §2.4 gate-2 quarantine. `since = null` clears it. */
|
||||
async function setPendingEmpty(moduleId, since) {
|
||||
await query(
|
||||
`INSERT INTO team_sync_state (module_id, pending_empty_since) VALUES (?, ?)
|
||||
ON DUPLICATE KEY UPDATE pending_empty_since = VALUES(pending_empty_since)`,
|
||||
[moduleId, since],
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
activeByModule,
|
||||
allActive,
|
||||
findActive,
|
||||
findById,
|
||||
findBySlug,
|
||||
slugsLike,
|
||||
insertTeam,
|
||||
updateTeam,
|
||||
archiveTeam,
|
||||
recount,
|
||||
markRosterSynced,
|
||||
setMembersEmptySince,
|
||||
membersByTeam,
|
||||
memberKeys,
|
||||
findMember,
|
||||
activeByUser,
|
||||
activeTeamsForUser,
|
||||
upsertMember,
|
||||
markDeparted,
|
||||
setLeaders,
|
||||
setMemberLeader,
|
||||
syncState,
|
||||
recordAttempt,
|
||||
recordFailure,
|
||||
recordSuccess,
|
||||
setPendingEmpty,
|
||||
}
|
||||
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,
|
||||
}
|
||||
@@ -170,6 +170,15 @@ async function boot({ modules, model } = {}) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The Team reconciler's boot trigger (TEAMS.md §2.4), last — after every module
|
||||
// has started, because the provider is registered by a module and a module that
|
||||
// warms a cache in onBoot must be allowed to finish before it is asked anything.
|
||||
//
|
||||
// `safe` for the same reason every step above uses it: an unreachable provider
|
||||
// is a stale projection, never a site that will not start.
|
||||
// eslint-disable-next-line global-require
|
||||
await safe('starting the team reconciler', () => require('../model/teams/teamSync.model').start())
|
||||
}
|
||||
|
||||
/** Reject if `fn`'s promise has not settled within `ms`. */
|
||||
|
||||
@@ -119,6 +119,7 @@ function buildCtx(id, moduleRoot) {
|
||||
const uploads = require('../router/v1/admin/imageUpload')
|
||||
const activity = require('../model/activity/activity.model')
|
||||
const users = require('../model/users/users.model')
|
||||
const teams = require('../model/teams/teamSync.model')
|
||||
const { makeLimiter, accountChangeLimiter } = require('../middleware/rateLimit')
|
||||
/* eslint-enable global-require */
|
||||
|
||||
@@ -174,6 +175,31 @@ function buildCtx(id, moduleRoot) {
|
||||
// a place nobody looks. `list` stays core's: reading the log is the admin
|
||||
// panel's job, and it spans every actor.
|
||||
activity: { log: activity.log },
|
||||
// Teams (API 1.6.0, TEAMS.md §2.3). Push, to the pull the provider answers.
|
||||
//
|
||||
// Both are fire-and-forget by contract. `publish` is an OPTIMISATION — it
|
||||
// makes a membership change visible at once — and `reconcile` is a REQUEST,
|
||||
// debounced and never awaited, so a module cannot make its own call site slow
|
||||
// or turn a background failure into its own error. Correctness comes from the
|
||||
// reconciler either way; these only decide how soon.
|
||||
//
|
||||
// There is deliberately no reader here. A module answers questions about
|
||||
// Teams; it does not ask them. Every Team table is core-internal (§10.3), and
|
||||
// a `getTeamRoster` on ctx would be core offering to read back the module's
|
||||
// own answer — which is the module's data, in the module's own store.
|
||||
teams: {
|
||||
publish: (event) => teams.publish(event),
|
||||
reconcile: (opts) => teams.request(opts),
|
||||
// §4's activity feed, which lands with the Team pages in phase 3. Declared
|
||||
// in 1.6.0 alongside the rest of the Team surface; calling it before phase 3
|
||||
// throws rather than silently accepting items into a table that does not
|
||||
// exist yet.
|
||||
activity: {
|
||||
push: () => {
|
||||
throw new Error('ctx.teams.activity.push is not available until the Team activity feed lands (TEAMS.md §4)')
|
||||
},
|
||||
},
|
||||
},
|
||||
// One function, for one caller: the `admin.users.detail` slot router needs
|
||||
// the user its prefix names. Narrowed like `ctx.posts` — the users model
|
||||
// exports creation, role changes and password handling, none of which is a
|
||||
@@ -240,6 +266,23 @@ function buildApi(record) {
|
||||
record.staged.registerNotificationStreams(streams)
|
||||
},
|
||||
registerAnnounceLeg: record.staged.registerAnnounceLeg,
|
||||
// The Team provider (API 1.6.0, TEAMS.md §2.3). Unlike every registration
|
||||
// above, this one is core CALLING THE MODULE and waiting for an answer — the
|
||||
// same direction registerAnnounceLeg's dispatch already goes, which is why it
|
||||
// is modelled on it rather than invented. `once` because a module registering
|
||||
// twice means two answers to a question that has one.
|
||||
registerTeamProvider(provider) {
|
||||
once('registerTeamProvider')
|
||||
record.staged.registerTeamProvider(provider)
|
||||
},
|
||||
// Declared in 1.6.0 with the rest of the Team surface; the bot half that
|
||||
// executes a command lands in phase 7 (§7.1). Present and throwing rather
|
||||
// than absent, so a module written against the published version fails at
|
||||
// registration with a sentence naming the phase, instead of at whatever
|
||||
// moment someone first types the command.
|
||||
registerSlashCommands() {
|
||||
throw new Error('api.registerSlashCommands is not available until Discord slash commands land (TEAMS.md §7.1)')
|
||||
},
|
||||
// The two lifecycle hooks (§2.5). Registered here, dispatched from
|
||||
// lifecycle.js — this file runs with no database and the hooks run with one.
|
||||
// Both are optional: a module with no warm-up and nothing to close simply
|
||||
|
||||
@@ -58,6 +58,16 @@ const legs = new Map()
|
||||
// a collision with a name attached rather than a silently doubled side effect.
|
||||
const postHooks = new Map()
|
||||
|
||||
// { owner, getTeams, getTeamMembers, getTeamLeaders } or null — the Team provider
|
||||
// (API 1.6.0, TEAMS.md §2.3).
|
||||
//
|
||||
// A SINGLE value rather than a Map, unlike every registry above it, and that is
|
||||
// the contract: one provider per deployment. Teams have one authoritative source
|
||||
// by construction — two modules answering "what teams exist" would produce two
|
||||
// disjoint sets under one `teams` table with no rule for merging them, so a
|
||||
// second registration is a collision rather than an addition.
|
||||
let teamProvider = null
|
||||
|
||||
let coreRegistered = false
|
||||
|
||||
// Stream ids that predate the module system and may not carry their owner's
|
||||
@@ -196,6 +206,14 @@ const announceLegIds = () => [...legs.keys()]
|
||||
/** One leg, or null. */
|
||||
const announceLeg = (leg) => legs.get(leg) || null
|
||||
|
||||
// ── Team provider (TEAMS.md §2.3) ──────────────────────────────────────────
|
||||
|
||||
/** The registered provider, or null when no module supplies one. */
|
||||
const registeredTeamProvider = () => teamProvider
|
||||
|
||||
/** Is there a Team provider at all? Read by the reconciler and the read API. */
|
||||
const hasTeamProvider = () => teamProvider !== null
|
||||
|
||||
// ── Shape checks, run the moment a registrant calls ────────────────────────
|
||||
//
|
||||
// Split from the collision checks below on the same line PR 3 drew through
|
||||
@@ -225,6 +243,23 @@ function checkLegShape(entry) {
|
||||
return { leg, label: label || leg, dispatch, classify }
|
||||
}
|
||||
|
||||
// All three methods are REQUIRED, with no optional half. A provider that could
|
||||
// list Teams but not their members would leave core holding Teams it can never
|
||||
// populate, and the reconciler has no sensible behaviour for that — it is not the
|
||||
// same as a call that fails, which is staleness and already handled (§2.4). A
|
||||
// module unable to answer one of the three answers `{ ok: false }` at call time.
|
||||
function checkTeamProviderShape(entry) {
|
||||
const provider = entry || {}
|
||||
const out = {}
|
||||
for (const name of ['getTeams', 'getTeamMembers', 'getTeamLeaders']) {
|
||||
if (typeof provider[name] !== 'function') {
|
||||
throw new Error(`registerTeamProvider: ${name}() is missing or not a function`)
|
||||
}
|
||||
out[name] = provider[name]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* `registerPostHook({ onSaved, onDeleted })` — both optional, at least one
|
||||
* required. A registration with neither is a subscription that can never fire,
|
||||
@@ -265,7 +300,7 @@ function checkExtensionShape(slot, router, specFile) {
|
||||
* `allStreams()` / `announceLeg()` / the slot routers until `apply()`.
|
||||
*/
|
||||
function stage(owner) {
|
||||
const staged = { owner, streams: [], legs: [], extensions: [], postHooks: [] }
|
||||
const staged = { owner, streams: [], legs: [], extensions: [], postHooks: [], teamProviders: [] }
|
||||
return {
|
||||
staged,
|
||||
registerNotificationStreams(entries) {
|
||||
@@ -281,6 +316,9 @@ function stage(owner) {
|
||||
registerPostHook(entry) {
|
||||
staged.postHooks.push(checkPostHookShape(entry))
|
||||
},
|
||||
registerTeamProvider(entry) {
|
||||
staged.teamProviders.push(checkTeamProviderShape(entry))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,7 +331,14 @@ function stage(owner) {
|
||||
* PR 2 learned to protect (mounting inside the scan loop made every collision
|
||||
* look like it was with core).
|
||||
*/
|
||||
function apply({ owner, streams: newStreams, legs: newLegs, extensions: newExtensions, postHooks: newPostHooks = [] }) {
|
||||
function apply({
|
||||
owner,
|
||||
streams: newStreams,
|
||||
legs: newLegs,
|
||||
extensions: newExtensions,
|
||||
postHooks: newPostHooks = [],
|
||||
teamProviders: newTeamProviders = [],
|
||||
}) {
|
||||
// ── validate ──
|
||||
const seenStreams = new Set()
|
||||
for (const s of newStreams) {
|
||||
@@ -332,6 +377,11 @@ function apply({ owner, streams: newStreams, legs: newLegs, extensions: newExten
|
||||
throw new Error(`"${owner}" already registered a post hook`)
|
||||
}
|
||||
|
||||
if (newTeamProviders.length > 1) throw new Error(`"${owner}" registered more than one team provider`)
|
||||
if (newTeamProviders.length && teamProvider) {
|
||||
throw new Error(`a team provider is already registered by "${teamProvider.owner}"`)
|
||||
}
|
||||
|
||||
// ── commit — nothing below can fail ──
|
||||
for (const s of newStreams) {
|
||||
streamOwners.set(s.id, owner)
|
||||
@@ -345,6 +395,7 @@ function apply({ owner, streams: newStreams, legs: newLegs, extensions: newExten
|
||||
entry.router.use(x.router)
|
||||
}
|
||||
for (const h of newPostHooks) postHooks.set(owner, h)
|
||||
for (const p of newTeamProviders) teamProvider = { owner, ...p }
|
||||
}
|
||||
|
||||
// ── Core's own registrations ───────────────────────────────────────────────
|
||||
@@ -410,6 +461,7 @@ function _reset() {
|
||||
streamOwners.clear()
|
||||
legs.clear()
|
||||
postHooks.clear()
|
||||
teamProvider = null
|
||||
coreRegistered = false
|
||||
}
|
||||
|
||||
@@ -427,6 +479,8 @@ module.exports = {
|
||||
announceLeg,
|
||||
postHookEntries,
|
||||
dispatchPostHook,
|
||||
registeredTeamProvider,
|
||||
hasTeamProvider,
|
||||
stage,
|
||||
apply,
|
||||
registerCore,
|
||||
|
||||
@@ -9,6 +9,21 @@
|
||||
// Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and
|
||||
// has nothing to say about a website module) and from any module's own version.
|
||||
|
||||
// 1.6.0 — the Team surface (docs/website/TEAMS.md Part 11). Additions only, so
|
||||
// minor: `api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders })`,
|
||||
// `ctx.teams.publish(event)`, `ctx.teams.reconcile({ reason })`,
|
||||
// `ctx.teams.activity.push(items)`, `api.registerSlashCommands([...])`, and the
|
||||
// client slots `team.overview` / `team.member.row`. module-uo's `coreApi:
|
||||
// "^1.3.0"` still resolves.
|
||||
//
|
||||
// **The number covers the whole surface; the members arrive by phase.** The three
|
||||
// this phase implements are live. `activity.push` lands with the Team activity
|
||||
// feed (§4, phase 3) and `registerSlashCommands` with the Discord commands (§7.1,
|
||||
// phase 7) — until then each is present and THROWS rather than being absent or,
|
||||
// worse, silently accepting data into a table that does not exist. MODULE_API.md
|
||||
// names the phase against each member, so a module author reads what is callable
|
||||
// today rather than discovering it at runtime.
|
||||
//
|
||||
// 1.5.0 — a CLIENT addition: `PublicLayout` takes an optional `shell` prop that
|
||||
// renders the page body wrapper core's own pages write by hand (MODULE_API.md
|
||||
// §3.4). Minor, not major: §3.4 makes *changing* a kit component's props a major
|
||||
@@ -44,6 +59,6 @@
|
||||
// an admin action a module performs belongs in core's one audit log, the
|
||||
// extension slot needs the user its prefix names, and §2.7 forbids a module
|
||||
// reading core's `APP_BASE_URL` for itself. Additions only, so minor.
|
||||
const MODULE_API_VERSION = '1.5.0'
|
||||
const MODULE_API_VERSION = '1.6.0'
|
||||
|
||||
module.exports = { MODULE_API_VERSION }
|
||||
|
||||
@@ -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
|
||||
212
server/src/utils/reservedNames.js
Normal file
212
server/src/utils/reservedNames.js
Normal file
@@ -0,0 +1,212 @@
|
||||
// ── Reserved-name screening ────────────────────────────────────────────────
|
||||
//
|
||||
// The one place untrusted game data becomes a public page (TEAMS.md §2.8).
|
||||
//
|
||||
// A Team's name is written by a player, inside the game, with no review, and the
|
||||
// platform then turns it into a public page, a URL, a nav-reachable entity and
|
||||
// eventually a Discord channel name. Someone naming their guild "Admin",
|
||||
// "Moderator" or "<Brand> Staff" gets an official-looking page on the operator's
|
||||
// own site for free, by typing a name into a guild stone.
|
||||
//
|
||||
// **Hide, never reject.** Core cannot refuse a name: the guild already exists in
|
||||
// the game and core is a mirror of it, not an authority over it. A match hides
|
||||
// the Team from public surfaces and puts it in a review queue, and it keeps
|
||||
// working completely for its own members — the people in it are not being
|
||||
// punished for a name their leader chose.
|
||||
//
|
||||
// That asymmetry is what lets this matcher be conservative without being clever:
|
||||
// **a false positive costs a human glance, a false negative costs an impersonated
|
||||
// staff page.**
|
||||
//
|
||||
// NOT `filter_words`. That table exists but is bot-owned (its own pool, never
|
||||
// read by the website — MODERATION_APPEALS.md §2), and it is a profanity filter,
|
||||
// which is a different question with a different answer. Reusing it would cross
|
||||
// an ownership boundary to get the wrong list.
|
||||
//
|
||||
// Also NOT `auth/usernamePolicy.js`'s RESERVED_USERNAMES. That list answers
|
||||
// "may someone register under this handle", matched exactly against a whole
|
||||
// username; this one answers "does this phrase impersonate authority", matched
|
||||
// word by word inside a name that is usually several words long. Sharing them
|
||||
// would give each question the other's answer — "Support" is a fine guild name
|
||||
// and an unacceptable username.
|
||||
|
||||
const brand = require('../config/brand')
|
||||
const settings = require('../model/settings/settings.model')
|
||||
const log = require('./logger')('teams')
|
||||
|
||||
// The `users.role` enum plus the words people actually use for those roles. Kept
|
||||
// here rather than derived from the enum alone, because 'gm' and 'staff' are not
|
||||
// roles in the database and are exactly what a would-be impersonator reaches for.
|
||||
const ROLE_TERMS = [
|
||||
'admin', 'editor', 'moderator', 'player',
|
||||
'staff', 'administrator', 'mod', 'owner', 'gm',
|
||||
]
|
||||
|
||||
// Impersonating the software project is as much a problem as impersonating the
|
||||
// operator. Stored in its correct two-word form; §2.8.2's whitespace-insensitive
|
||||
// comparison is what also catches RunicGateway, runic-gateway and Runic_Gateway.
|
||||
const PROJECT_TERMS = ['Runic Gateway']
|
||||
|
||||
const OPERATOR_TERMS_KEY = 'teams_reserved_terms'
|
||||
|
||||
/**
|
||||
* Case-fold, strip punctuation, collapse repeats and whitespace.
|
||||
*
|
||||
* Repeated characters are squeezed so "Adminnn" folds to "admin". Deliberately
|
||||
* NO leet-speak folding in v1 (`4dm1n`): it multiplies false positives, and the
|
||||
* consequence of a miss is a Team hidden by a human rather than a breach.
|
||||
*/
|
||||
function normalise(value) {
|
||||
return String(value || '')
|
||||
.normalize('NFKD')
|
||||
.replace(/[̀-ͯ]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s]+/g, ' ')
|
||||
.replace(/(.)\1{1,}/g, '$1')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
const words = (value) => (value ? value.split(' ') : [])
|
||||
|
||||
/**
|
||||
* The words of a name, plus the acronyms its punctuation was hiding.
|
||||
*
|
||||
* "G.M." normalises to `g m`, and neither token is the reserved term `gm` — so a
|
||||
* run of two or more single-letter words is ALSO offered as one joined token.
|
||||
* "GM" is a live impersonation vector on a game server, and spelling it with dots
|
||||
* is the obvious way around a word-level check.
|
||||
*
|
||||
* The individual letters are kept as well as the joined form, so this only ever
|
||||
* adds matches. And the join is deliberately not the whole-name condensation used
|
||||
* for multi-word terms: condensing every name would let a single-word term match
|
||||
* inside an ordinary word again, which is the substring matching this whole design
|
||||
* refuses.
|
||||
*/
|
||||
function tokens(normalised) {
|
||||
const list = words(normalised)
|
||||
const out = [...list]
|
||||
let run = []
|
||||
const flush = () => {
|
||||
if (run.length > 1) out.push(run.join(''))
|
||||
run = []
|
||||
}
|
||||
for (const word of list) {
|
||||
if (word.length === 1) run.push(word)
|
||||
else flush()
|
||||
}
|
||||
flush()
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* A single-word term matches a name word, or that word's singular.
|
||||
*
|
||||
* A guild called "Moderators" impersonates staff exactly as much as one called
|
||||
* "Moderator", and a check that misses the plural misses the more natural name of
|
||||
* the two. Only a trailing `s` is stripped, and only when the remainder is the
|
||||
* whole term — so "Nomads" still does not match "mod" and "Playerless" still does
|
||||
* not match "player".
|
||||
*/
|
||||
const wordMatches = (word, term) =>
|
||||
word === term || (word.length > 1 && word.endsWith('s') && word.slice(0, -1) === term)
|
||||
|
||||
/**
|
||||
* Every reserved term for this deployment, resolved AT CHECK TIME.
|
||||
*
|
||||
* Never baked in: the brand is runtime configuration, so a deployment that
|
||||
* renames itself must be protected under its new name without a redeploy.
|
||||
*
|
||||
* A settings read that fails must not open the gate, so a failure falls back to
|
||||
* the static terms rather than to an empty list — screening fewer terms is bad,
|
||||
* screening none is the whole hole.
|
||||
*/
|
||||
async function reservedTerms() {
|
||||
const terms = [...ROLE_TERMS, ...PROJECT_TERMS]
|
||||
|
||||
try {
|
||||
const instanceName = await settings.getInstanceName()
|
||||
if (instanceName) terms.push(instanceName)
|
||||
} catch (err) {
|
||||
log.warn('could not resolve the instance name for reserved-name screening', { message: err.message })
|
||||
}
|
||||
|
||||
if (brand.name) terms.push(brand.name)
|
||||
if (brand.shortName) terms.push(brand.shortName)
|
||||
|
||||
try {
|
||||
const extra = await settings.get(OPERATOR_TERMS_KEY)
|
||||
if (extra) terms.push(...String(extra).split(',').map((t) => t.trim()).filter(Boolean))
|
||||
} catch (err) {
|
||||
log.warn('could not read operator reserved terms', { message: err.message })
|
||||
}
|
||||
|
||||
// De-duplicated on the normalised form: the brand and an operator term are
|
||||
// frequently the same word, and reporting the same match twice is noise in a
|
||||
// review queue.
|
||||
const seen = new Set()
|
||||
return terms.filter((term) => {
|
||||
const key = normalise(term)
|
||||
if (!key || seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Does `name` contain `term`?
|
||||
*
|
||||
* Whole WORDS, after normalisation — never substrings. Core already has the
|
||||
* precedent and the scar tissue for this: scripts/checkModuleIdentifiers.js
|
||||
* tokenises and compares word by word precisely so `defaultImage` does not match
|
||||
* "ultIma". The same discipline applies for the same reason — a substring match
|
||||
* flags "Badminton" for containing "admin", and a check that cries wolf is a
|
||||
* check people switch off.
|
||||
*
|
||||
* A MULTI-WORD term is additionally compared with the whitespace removed on both
|
||||
* sides, so "Runic Gateway" matches "RunicGateway". Without that the whole-word
|
||||
* rule fails on exactly the case that matters: the condensed form is a SINGLE
|
||||
* word and could never match a two-word term — and it is the form an impersonator
|
||||
* would reach for, because it is what the Gitea org and every URL already use.
|
||||
*
|
||||
* The widening applies only to terms containing whitespace, which keeps it away
|
||||
* from the single-word terms where whole-word matching is doing the false-positive
|
||||
* work. A two-word term is specific enough that running its letters together
|
||||
* cannot collide with ordinary vocabulary.
|
||||
*/
|
||||
function matches(nameWords, condensedName, term) {
|
||||
const normalisedTerm = normalise(term)
|
||||
if (!normalisedTerm) return false
|
||||
const termWords = words(normalisedTerm)
|
||||
|
||||
if (termWords.length === 1) return nameWords.some((w) => wordMatches(w, termWords[0]))
|
||||
|
||||
// A multi-word term matches as a consecutive run of words …
|
||||
for (let i = 0; i + termWords.length <= nameWords.length; i++) {
|
||||
if (termWords.every((w, j) => nameWords[i + j] === w)) return true
|
||||
}
|
||||
// … or as its condensed form appearing as a whole word in the condensed name.
|
||||
const condensedTerm = termWords.join('')
|
||||
return condensedName.includes(condensedTerm)
|
||||
}
|
||||
|
||||
/**
|
||||
* Screen a name. Returns `{ reserved, term }` — `term` is the term that matched,
|
||||
* in its stored form, which is what the review queue shows a human.
|
||||
*/
|
||||
async function screen(name) {
|
||||
const normalised = normalise(name)
|
||||
if (!normalised) return { reserved: false, term: null }
|
||||
|
||||
const nameWords = tokens(normalised)
|
||||
// The condensed name is the whole thing with spaces removed, so a multi-word
|
||||
// term can be found inside a run-together name.
|
||||
const condensed = words(normalised).join('')
|
||||
|
||||
for (const term of await reservedTerms()) {
|
||||
if (matches(nameWords, condensed, term)) return { reserved: true, term }
|
||||
}
|
||||
return { reserved: false, term: null }
|
||||
}
|
||||
|
||||
module.exports = { screen, normalise, reservedTerms, ROLE_TERMS, PROJECT_TERMS, OPERATOR_TERMS_KEY }
|
||||
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 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -507,9 +507,13 @@ test('ctx exposes exactly the documented surface, and is frozen', () => {
|
||||
// extraction needed it and none could be vendored: an admin action a module
|
||||
// performs belongs in core's one audit log, the extension slot needs the user
|
||||
// its prefix names, and §2.7 forbids a module reading core's APP_BASE_URL.
|
||||
// API 1.6.0 added `teams` — push, to the pull the team provider answers
|
||||
// (TEAMS.md §2.3). Read-only by omission: a module answers questions about
|
||||
// Teams and never asks them, so there is no getter here to add later by
|
||||
// accident.
|
||||
assert.deepEqual(probe.keys, [
|
||||
'activity', 'auth', 'db', 'express', 'log', 'middleware', 'moduleId', 'paths',
|
||||
'posts', 'push', 'secretBox', 'settings', 'site', 'uploads', 'users', 'validator',
|
||||
'posts', 'push', 'secretBox', 'settings', 'site', 'teams', 'uploads', 'users', 'validator',
|
||||
])
|
||||
// is core's limiter FACTORY, not a limiter: a module states its own
|
||||
// window and cap and takes the plumbing, so there is one express-rate-limit in
|
||||
|
||||
192
server/test/reservedNames.test.js
Normal file
192
server/test/reservedNames.test.js
Normal file
@@ -0,0 +1,192 @@
|
||||
// Reserved-name screening (docs/website/TEAMS.md §2.8).
|
||||
//
|
||||
// Two failure modes with very different costs, and the tests are split along
|
||||
// that line:
|
||||
//
|
||||
// - a FALSE NEGATIVE puts an official-looking staff page on the operator's own
|
||||
// site, written by whoever typed a name into a guild stone;
|
||||
// - a FALSE POSITIVE hides a legitimate guild until a human glances at a queue.
|
||||
//
|
||||
// The second is cheap and recoverable, which is what lets the matcher be
|
||||
// conservative. It is not licence to be sloppy in the other direction: a check
|
||||
// that fires on "Badminton" is a check the operator switches off, and then the
|
||||
// first cost is paid in full.
|
||||
const { test, beforeEach, afterEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const settings = require('../src/model/settings/settings.model')
|
||||
const brand = require('../src/config/brand')
|
||||
const reserved = require('../src/utils/reservedNames')
|
||||
|
||||
const saved = []
|
||||
function patch(mod, name, fn) {
|
||||
saved.push([mod, name, mod[name]])
|
||||
mod[name] = fn
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// A deployment with a two-word brand and no operator additions, which is the
|
||||
// shape that exercises the condensed-form rule.
|
||||
patch(settings, 'getInstanceName', async () => 'UO Mysticmoon')
|
||||
patch(settings, 'get', async () => null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
while (saved.length) {
|
||||
const [mod, name, fn] = saved.pop()
|
||||
mod[name] = fn
|
||||
}
|
||||
})
|
||||
|
||||
const isReserved = async (name) => (await reserved.screen(name)).reserved
|
||||
const termFor = async (name) => (await reserved.screen(name)).term
|
||||
|
||||
// ── The names this exists to catch ─────────────────────────────────────────
|
||||
|
||||
test('bare role names are reserved', async () => {
|
||||
for (const name of ['Admin', 'admin', 'ADMIN', 'Moderator', 'Staff', 'Owner', 'GM', 'Administrator']) {
|
||||
assert.equal(await isReserved(name), true, `"${name}" must not become a public page`)
|
||||
}
|
||||
})
|
||||
|
||||
test('a role word inside a longer name is caught', async () => {
|
||||
for (const name of ['The Admin Team', 'Server Staff', 'GM Council', 'Guild of Moderators']) {
|
||||
assert.equal(await isReserved(name), true, `"${name}" is the impersonation this exists for`)
|
||||
}
|
||||
})
|
||||
|
||||
test('the deployment brand is reserved, in both presentations', async () => {
|
||||
assert.equal(await isReserved('UO Mysticmoon'), true)
|
||||
assert.equal(await isReserved('UOMysticmoon'), true, 'the condensed form is what an impersonator types')
|
||||
assert.equal(await isReserved('uo-mysticmoon'), true)
|
||||
assert.equal(await isReserved('UO_MYSTICMOON'), true)
|
||||
assert.equal(await isReserved('UOMysticmoon Staff'), true)
|
||||
})
|
||||
|
||||
test('the project name is reserved, in both of its legitimate presentations', async () => {
|
||||
// "Runic Gateway" is correct; "RunicGateway" is what the Gitea org and every
|
||||
// URL segment use, so it is the form someone would copy.
|
||||
assert.equal(await isReserved('Runic Gateway'), true)
|
||||
assert.equal(await isReserved('RunicGateway'), true)
|
||||
assert.equal(await isReserved('runic-gateway'), true)
|
||||
assert.equal(await isReserved('Runic_Gateway'), true)
|
||||
assert.equal(await isReserved('RUNIC GATEWAY'), true)
|
||||
})
|
||||
|
||||
test('operator additions are honoured', async () => {
|
||||
patch(settings, 'get', async (key) => (key === reserved.OPERATOR_TERMS_KEY ? 'Council, Arbiter' : null))
|
||||
assert.equal(await isReserved('The Council'), true)
|
||||
assert.equal(await isReserved('Arbiter'), true)
|
||||
})
|
||||
|
||||
test('repeated characters are squeezed', async () => {
|
||||
assert.equal(await isReserved('Adminnn'), true)
|
||||
assert.equal(await isReserved('Staaaff'), true)
|
||||
})
|
||||
|
||||
test('punctuation between words does not evade the check', async () => {
|
||||
assert.equal(await isReserved('[Admin]'), true)
|
||||
assert.equal(await isReserved('~*~ Staff ~*~'), true)
|
||||
assert.equal(await isReserved('G.M.'), true)
|
||||
})
|
||||
|
||||
test('the matched term is reported, for the review queue', async () => {
|
||||
assert.equal(await termFor('The Admin Team'), 'admin')
|
||||
assert.equal(await termFor('UOMysticmoon'), 'UO Mysticmoon', 'shown in its stored form, not the input')
|
||||
})
|
||||
|
||||
// ── The names it must NOT catch ────────────────────────────────────────────
|
||||
|
||||
test('a word merely CONTAINING a reserved term is not reserved', async () => {
|
||||
// The scar tissue this rule comes from: checkModuleIdentifiers.js tokenises
|
||||
// precisely so `defaultImage` does not match "ultIma".
|
||||
for (const name of ['Badminton', 'Badminton Club', 'Modest Proposal', 'Gmork', 'Playerless']) {
|
||||
assert.equal(await isReserved(name), false, `"${name}" is a false positive that would discredit the check`)
|
||||
}
|
||||
})
|
||||
|
||||
test('ordinary guild names pass', async () => {
|
||||
for (const name of [
|
||||
'The Silver Hand', 'Knights of the Round', 'Dread Pirates', 'Moonlight Traders',
|
||||
'The Guardians', 'Iron Wolves',
|
||||
]) {
|
||||
assert.equal(await isReserved(name), false, `"${name}" is an ordinary guild`)
|
||||
}
|
||||
})
|
||||
|
||||
test('the condensed-form widening applies only to multi-word terms', async () => {
|
||||
// Running the letters together is safe for a two-word term because it is
|
||||
// specific; doing it for single-word terms is what would re-introduce
|
||||
// substring matching through the back door.
|
||||
assert.equal(await isReserved('Badminton'), false)
|
||||
assert.equal(await isReserved('Grandmaster'), false, 'contains "gm" only as a substring')
|
||||
assert.equal(await isReserved('Nomads'), false, 'contains "mod" only as a substring')
|
||||
})
|
||||
|
||||
test('an empty or unusable name is not reserved', async () => {
|
||||
for (const name of ['', ' ', null, undefined, '★☆★']) {
|
||||
assert.equal(await isReserved(name), false)
|
||||
}
|
||||
})
|
||||
|
||||
// ── Resolution is at check time, and fails safe ────────────────────────────
|
||||
|
||||
test('the brand is resolved at CHECK time, so a rename protects the new name', async () => {
|
||||
patch(settings, 'getInstanceName', async () => 'Dragonspire')
|
||||
assert.equal(await isReserved('Dragonspire'), true)
|
||||
|
||||
patch(settings, 'getInstanceName', async () => 'Emberfall')
|
||||
assert.equal(await isReserved('Emberfall'), true, 'no redeploy should be needed to protect a new brand')
|
||||
})
|
||||
|
||||
test('a failed settings read falls back to the static terms rather than to none', async () => {
|
||||
// Screening fewer terms is bad; screening none is the entire hole.
|
||||
patch(settings, 'getInstanceName', async () => { throw new Error('db down') })
|
||||
patch(settings, 'get', async () => { throw new Error('db down') })
|
||||
|
||||
assert.equal(await isReserved('Admin'), true, 'the role list must survive a database outage')
|
||||
assert.equal(await isReserved('Runic Gateway'), true)
|
||||
})
|
||||
|
||||
test('BRAND_NAME is covered even when no site_title is set', async () => {
|
||||
patch(settings, 'getInstanceName', async () => null)
|
||||
assert.equal(await isReserved(brand.name), true)
|
||||
})
|
||||
|
||||
test('the same term resolved twice is listed once', async () => {
|
||||
// The brand and an operator term are frequently the same word, and reporting
|
||||
// one match twice is noise in a queue a human reads.
|
||||
patch(settings, 'getInstanceName', async () => 'Dragonspire')
|
||||
patch(settings, 'get', async (key) => (key === reserved.OPERATOR_TERMS_KEY ? 'dragonspire' : null))
|
||||
const terms = await reserved.reservedTerms()
|
||||
const normalised = terms.map((t) => reserved.normalise(t))
|
||||
assert.equal(new Set(normalised).size, normalised.length)
|
||||
})
|
||||
|
||||
test('normalise folds case, diacritics and punctuation', () => {
|
||||
assert.equal(reserved.normalise('Ünderdärk!'), 'underdark')
|
||||
assert.equal(reserved.normalise(' The Silver Hand '), 'the silver hand')
|
||||
assert.equal(reserved.normalise('Adminnn'), 'admin', 'repeats are squeezed on both sides')
|
||||
})
|
||||
|
||||
test('plurals are caught, and near-misses are not', async () => {
|
||||
// "Moderators" is the more natural guild name of the two, so missing it would
|
||||
// miss the likelier case.
|
||||
for (const name of ['Moderators', 'The Admins', 'Guild of Moderators', 'Owners']) {
|
||||
assert.equal(await isReserved(name), true, `"${name}" impersonates as much as its singular`)
|
||||
}
|
||||
// Only a trailing s off the WHOLE term, so an ordinary word whose stem merely
|
||||
// contains one does not fire.
|
||||
for (const name of ['Nomads', 'Playerless', 'Gods']) {
|
||||
assert.equal(await isReserved(name), false, `"${name}" is not a plural of a reserved term`)
|
||||
}
|
||||
})
|
||||
|
||||
test('an acronym spelled with punctuation is caught', async () => {
|
||||
// "G.M." normalises to two single-letter words, neither of which is the term.
|
||||
assert.equal(await isReserved('G.M.'), true)
|
||||
assert.equal(await isReserved('G M Council'), true)
|
||||
// …but joining single letters must not condense whole names, which would let a
|
||||
// single-word term match inside an ordinary word again.
|
||||
assert.equal(await isReserved('Badminton'), false)
|
||||
})
|
||||
209
server/test/teamAccess.test.js
Normal file
209
server/test/teamAccess.test.js
Normal file
@@ -0,0 +1,209 @@
|
||||
// The four authority paths, and the rule that they stay four
|
||||
// (docs/website/TEAMS.md §2.5).
|
||||
//
|
||||
// Two of these tests are named for invariants rather than for behaviour, because
|
||||
// what they protect is a structural property that a perfectly reasonable-looking
|
||||
// refactor destroys: "has forum access" is never read as "is a member", and a
|
||||
// grant never writes the membership projection. Both are one `||` away from being
|
||||
// wrong, and neither failure is visible on any screen — the first shows up as a
|
||||
// stranger on a public roster, the second as a Discord role handed to an account
|
||||
// nobody can tie to a real player.
|
||||
const { test, beforeEach, afterEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const accessDb = require('../src/model/teams/teamAccess.db')
|
||||
const teamsDb = require('../src/model/teams/teams.db')
|
||||
const identities = require('../src/model/userIdentities/userIdentities.model')
|
||||
const access = require('../src/model/teams/teamAccess.model')
|
||||
|
||||
const saved = []
|
||||
function patch(mod, name, fn) {
|
||||
saved.push([mod, name, mod[name]])
|
||||
mod[name] = fn
|
||||
}
|
||||
|
||||
// Every read the four paths can make, stubbed to "nothing there". Each test then
|
||||
// states only the fact it is about, which is what makes a crossed path obvious:
|
||||
// a resolver reading a table it should not would come back empty and the
|
||||
// assertion would say so.
|
||||
function stubAll() {
|
||||
patch(accessDb, 'activeGrant', async () => undefined)
|
||||
patch(accessDb, 'overrideFor', async () => undefined)
|
||||
patch(accessDb, 'overridesForTeam', async () => [])
|
||||
patch(teamsDb, 'activeByUser', async () => undefined)
|
||||
patch(teamsDb, 'membersByTeam', async () => [])
|
||||
patch(identities, 'listForUser', async () => [])
|
||||
}
|
||||
|
||||
const memberRow = (extra = {}) => ({
|
||||
team_id: 1, member_key: '0x1', user_id: 7, is_leader: 0, status: 'active', display_name: 'Aldric', ...extra,
|
||||
})
|
||||
const grantRow = (extra = {}) => ({ id: 1, team_id: 1, user_id: 7, granted_by: 2, revoked_at: null, ...extra })
|
||||
|
||||
beforeEach(stubAll)
|
||||
afterEach(() => {
|
||||
while (saved.length) {
|
||||
const [mod, name, fn] = saved.pop()
|
||||
mod[name] = fn
|
||||
}
|
||||
})
|
||||
|
||||
// ── Path 3: forum access is membership OR a grant ──────────────────────────
|
||||
|
||||
test('a member has forum access via membership', async () => {
|
||||
patch(teamsDb, 'activeByUser', async () => memberRow())
|
||||
const result = await access.forumAccess(1, 7)
|
||||
assert.deepEqual(result, { allowed: true, viaMembership: true, viaGrant: false, isLeader: false })
|
||||
})
|
||||
|
||||
test('a granted non-member has forum access via the grant', async () => {
|
||||
patch(accessDb, 'activeGrant', async () => grantRow())
|
||||
const result = await access.forumAccess(1, 7)
|
||||
assert.deepEqual(result, { allowed: true, viaMembership: false, viaGrant: true, isLeader: false })
|
||||
})
|
||||
|
||||
test('both reasons are reported when both hold', async () => {
|
||||
// Not collapsed into one boolean: both facts are true, membership is what the
|
||||
// UI shows as the current reason, and the grant stays as the record of who let
|
||||
// this person in before they were a member.
|
||||
patch(accessDb, 'activeGrant', async () => grantRow())
|
||||
patch(teamsDb, 'activeByUser', async () => memberRow())
|
||||
const result = await access.forumAccess(1, 7)
|
||||
assert.equal(result.viaMembership, true)
|
||||
assert.equal(result.viaGrant, true)
|
||||
})
|
||||
|
||||
test('a revoked grant and no membership is no access', async () => {
|
||||
// activeGrant returns nothing for a revoked row — the resolver never sees one.
|
||||
const result = await access.forumAccess(1, 7)
|
||||
assert.equal(result.allowed, false)
|
||||
})
|
||||
|
||||
test('an anonymous caller is refused without touching a table', async () => {
|
||||
let reads = 0
|
||||
patch(accessDb, 'activeGrant', async () => { reads += 1 })
|
||||
patch(teamsDb, 'activeByUser', async () => { reads += 1 })
|
||||
const result = await access.forumAccess(1, null)
|
||||
assert.equal(result.allowed, false)
|
||||
assert.equal(reads, 0)
|
||||
})
|
||||
|
||||
// ── Invariant 3: non-contamination ─────────────────────────────────────────
|
||||
|
||||
test('INVARIANT — a grant never writes the membership projection', async () => {
|
||||
// The grant path reads its own table and nothing else. Asserted by making every
|
||||
// membership WRITE explode: if resolving a grant ever wrote a member row, this
|
||||
// is where it would surface.
|
||||
for (const name of ['upsertMember', 'markDeparted', 'setLeaders', 'setMemberLeader']) {
|
||||
patch(teamsDb, name, async () => { throw new Error(`forumAccess wrote team_members via ${name}`) })
|
||||
}
|
||||
patch(accessDb, 'activeGrant', async () => grantRow())
|
||||
const result = await access.forumAccess(1, 7)
|
||||
assert.equal(result.allowed, true)
|
||||
assert.equal(result.viaMembership, false, 'a grant is not a membership, in either direction')
|
||||
})
|
||||
|
||||
test('INVARIANT — a granted, unlinked user is not on the roster', async () => {
|
||||
// The roster is path 1's table alone. A granted user with no membership row
|
||||
// appears nowhere in it, which is what keeps them out of every membership count.
|
||||
patch(accessDb, 'activeGrant', async () => grantRow({ user_id: 99 }))
|
||||
patch(teamsDb, 'membersByTeam', async () => [memberRow()])
|
||||
|
||||
const roster = await access.rosterWithOverrides(1)
|
||||
assert.equal(roster.length, 1)
|
||||
assert.equal(roster.every((m) => m.user_id !== 99), true, 'a forum guest is not a member')
|
||||
})
|
||||
|
||||
// ── Path 4: external access is blind to path 3 ─────────────────────────────
|
||||
|
||||
test('INVARIANT — a forum grant does not make an account externally eligible', async () => {
|
||||
// The named test from §2.5. An integration cannot verify that an unlinked,
|
||||
// forum-granted account corresponds to a real game member, so it must not hand
|
||||
// that account a privilege on a platform where impersonation has consequences.
|
||||
patch(accessDb, 'activeGrant', async () => grantRow())
|
||||
patch(identities, 'listForUser', async () => [{ provider: 'discord', subject: 'd1' }])
|
||||
|
||||
assert.equal(await access.externalEligible(1, 7, 'discord'), false)
|
||||
})
|
||||
|
||||
test('a linked member with a linked Discord identity is eligible', async () => {
|
||||
patch(teamsDb, 'activeByUser', async () => memberRow({ user_id: 7 }))
|
||||
patch(identities, 'listForUser', async () => [{ provider: 'discord', subject: 'd1' }])
|
||||
assert.equal(await access.externalEligible(1, 7, 'discord'), true)
|
||||
})
|
||||
|
||||
test('a member with no Discord identity is not eligible — hop 3 of the chain', async () => {
|
||||
patch(teamsDb, 'activeByUser', async () => memberRow())
|
||||
patch(identities, 'listForUser', async () => [{ provider: 'google', subject: 'g1' }])
|
||||
assert.equal(await access.externalEligible(1, 7, 'discord'), false)
|
||||
})
|
||||
|
||||
test('eligibility is per platform, not "linked to anything"', async () => {
|
||||
patch(teamsDb, 'activeByUser', async () => memberRow())
|
||||
patch(identities, 'listForUser', async () => [{ provider: 'discord', subject: 'd1' }])
|
||||
assert.equal(await access.externalEligible(1, 7, 'matrix'), false)
|
||||
})
|
||||
|
||||
test('a non-member is never eligible', async () => {
|
||||
patch(identities, 'listForUser', async () => [{ provider: 'discord', subject: 'd1' }])
|
||||
assert.equal(await access.externalEligible(1, 7, 'discord'), false)
|
||||
})
|
||||
|
||||
// ── Path 2: leadership, and the staff override on top ──────────────────────
|
||||
|
||||
test('leadership follows the synced value when no override exists', async () => {
|
||||
patch(teamsDb, 'activeByUser', async () => memberRow({ is_leader: 1 }))
|
||||
assert.equal(await access.isLeaderByUser(1, 7), true)
|
||||
})
|
||||
|
||||
test('a deny override outranks a synced leader', async () => {
|
||||
patch(teamsDb, 'activeByUser', async () => memberRow({ is_leader: 1 }))
|
||||
patch(accessDb, 'overrideFor', async () => ({ member_key: '0x1', effect: 'deny' }))
|
||||
assert.equal(await access.isLeaderByUser(1, 7), false)
|
||||
})
|
||||
|
||||
test('a grant override promotes someone the game does not call a leader', async () => {
|
||||
patch(teamsDb, 'activeByUser', async () => memberRow({ is_leader: 0 }))
|
||||
patch(accessDb, 'overrideFor', async () => ({ member_key: '0x1', effect: 'grant' }))
|
||||
assert.equal(await access.isLeaderByUser(1, 7), true)
|
||||
})
|
||||
|
||||
test('an override survives a resync, because it is never written into the projection', async () => {
|
||||
// The projection keeps saying what the game says; the override keeps saying what
|
||||
// staff decided. Applied at READ time, so a sync fifteen minutes later cannot
|
||||
// undo it — which is the entire point of §2.5.1.
|
||||
patch(accessDb, 'overridesForTeam', async () => [
|
||||
{ member_key: '0x1', effect: 'deny', reason: 'harassment', actor_username: 'mod1', created_at: 'then' },
|
||||
])
|
||||
patch(teamsDb, 'membersByTeam', async () => [memberRow({ is_leader: 1 })])
|
||||
|
||||
const roster = await access.rosterWithOverrides(1)
|
||||
assert.equal(roster[0].is_leader, false, 'the resolved answer is the override')
|
||||
assert.equal(roster[0].is_leader_synced, true, 'what the game says is still visible')
|
||||
assert.equal(roster[0].leader_override.reason, 'harassment')
|
||||
assert.equal(roster[0].leader_override.by, 'mod1')
|
||||
})
|
||||
|
||||
test('a member with no override carries no override field', async () => {
|
||||
patch(teamsDb, 'membersByTeam', async () => [memberRow({ is_leader: 1 })])
|
||||
const roster = await access.rosterWithOverrides(1)
|
||||
assert.equal(roster[0].leader_override, null)
|
||||
assert.equal(roster[0].is_leader, true)
|
||||
})
|
||||
|
||||
test('leadership resolves through forumAccess too, override included', async () => {
|
||||
patch(teamsDb, 'activeByUser', async () => memberRow({ is_leader: 0 }))
|
||||
patch(accessDb, 'overrideFor', async () => ({ member_key: '0x1', effect: 'grant' }))
|
||||
const result = await access.forumAccess(1, 7)
|
||||
assert.equal(result.isLeader, true)
|
||||
})
|
||||
|
||||
test('a granted non-member is never a leader', async () => {
|
||||
// isLeader is path 2, which is a property of a MEMBER row. Someone with only a
|
||||
// forum grant has no member row, so there is nothing to promote.
|
||||
patch(accessDb, 'activeGrant', async () => grantRow())
|
||||
patch(accessDb, 'overrideFor', async () => ({ member_key: '0x1', effect: 'grant' }))
|
||||
const result = await access.forumAccess(1, 7)
|
||||
assert.equal(result.allowed, true)
|
||||
assert.equal(result.isLeader, false)
|
||||
})
|
||||
293
server/test/teamModeration.test.js
Normal file
293
server/test/teamModeration.test.js
Normal file
@@ -0,0 +1,293 @@
|
||||
// Auto-hide and the §2.9 approval gate (docs/website/TEAMS.md §2.8–§2.9).
|
||||
//
|
||||
// The gate's SCOPE is what these tests pin down, and it is the thing most likely
|
||||
// to be widened by accident. Three actions are gated because they publish
|
||||
// untrusted game-sourced strings; everything else staff can do still applies at
|
||||
// once. Gating more would make this a general staff-approval workflow, which is a
|
||||
// different and much larger idea — and gating admins would wedge the
|
||||
// single-admin deployments `npm run seed` creates.
|
||||
const { test, beforeEach, afterEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const moderationDb = require('../src/model/teams/teamModeration.db')
|
||||
const teamsDb = require('../src/model/teams/teams.db')
|
||||
const activity = require('../src/model/activity/activity.model')
|
||||
const reservedNames = require('../src/utils/reservedNames')
|
||||
const moderation = require('../src/model/teams/teamModeration.model')
|
||||
|
||||
const saved = []
|
||||
function patch(mod, name, fn) {
|
||||
saved.push([mod, name, mod[name]])
|
||||
mod[name] = fn
|
||||
}
|
||||
|
||||
let db
|
||||
let logged
|
||||
|
||||
const admin = { id: 1, username: 'root', role: 'admin' }
|
||||
const mod = { id: 2, username: 'mod1', role: 'moderator' }
|
||||
|
||||
function stub() {
|
||||
db = {
|
||||
teams: new Map([[1, { id: 1, name: 'Admin', slug: 'admin', hidden: 1, hidden_reason: 'reserved_name' }]]),
|
||||
requests: new Map(),
|
||||
nextRequestId: 1,
|
||||
}
|
||||
logged = []
|
||||
|
||||
patch(teamsDb, 'findById', async (id) => db.teams.get(id))
|
||||
patch(moderationDb, 'setHidden', async (id, { hidden, reason, term }) => {
|
||||
const t = db.teams.get(id)
|
||||
Object.assign(t, { hidden: hidden ? 1 : 0, hidden_reason: hidden ? reason : null, hidden_term: hidden ? term : null })
|
||||
})
|
||||
patch(moderationDb, 'markNameReviewed', async (id) => { db.teams.get(id).name_reviewed_at = 'now' })
|
||||
patch(moderationDb, 'setDisplayNameOverride', async (id, value) => {
|
||||
db.teams.get(id).display_name_override = value
|
||||
})
|
||||
patch(moderationDb, 'insertRequest', async (row) => {
|
||||
const id = db.nextRequestId++
|
||||
db.requests.set(id, { id, status: 'pending', ...row, payload: row.payload, team_id: row.teamId, requested_username: row.requestedUsername })
|
||||
return id
|
||||
})
|
||||
patch(moderationDb, 'findRequest', async (id) => db.requests.get(id))
|
||||
patch(moderationDb, 'decideRequest', async (id, { status, decidedUsername }) => {
|
||||
const r = db.requests.get(id)
|
||||
if (!r || r.status !== 'pending') return false
|
||||
Object.assign(r, { status, decided_username: decidedUsername })
|
||||
return true
|
||||
})
|
||||
patch(activity, 'log', async (entry) => { logged.push(entry) })
|
||||
}
|
||||
|
||||
beforeEach(stub)
|
||||
afterEach(() => {
|
||||
while (saved.length) {
|
||||
const [m, name, fn] = saved.pop()
|
||||
m[name] = fn
|
||||
}
|
||||
})
|
||||
|
||||
const actions = () => logged.map((l) => l.action)
|
||||
|
||||
// ── Auto-hide at create ────────────────────────────────────────────────────
|
||||
|
||||
test('a reserved name produces the hide columns a create should carry', async () => {
|
||||
patch(reservedNames, 'screen', async () => ({ reserved: true, term: 'admin' }))
|
||||
assert.deepEqual(await moderation.screenForCreate('Admin'), {
|
||||
hidden: true, hiddenReason: 'reserved_name', hiddenTerm: 'admin',
|
||||
})
|
||||
})
|
||||
|
||||
test('an ordinary name carries nothing', async () => {
|
||||
patch(reservedNames, 'screen', async () => ({ reserved: false, term: null }))
|
||||
assert.deepEqual(await moderation.screenForCreate('The Silver Hand'), { hidden: false })
|
||||
})
|
||||
|
||||
test('a screening failure creates the team unscreened rather than aborting the reconcile', async () => {
|
||||
// A deliberate trade: the re-screen on the next sync catches it, and a
|
||||
// reconcile that dies halfway through is worse than a name public for one
|
||||
// interval. It is also why re-screening exists rather than being create-only.
|
||||
patch(reservedNames, 'screen', async () => { throw new Error('settings unavailable') })
|
||||
assert.deepEqual(await moderation.screenForCreate('Admin'), { hidden: false })
|
||||
})
|
||||
|
||||
// ── Re-screening ───────────────────────────────────────────────────────────
|
||||
|
||||
test('a re-screen hides a team whose name became reserved', async () => {
|
||||
patch(moderationDb, 'unreviewedActive', async () => [{ id: 1, name: 'Council', hidden: 0 }])
|
||||
patch(reservedNames, 'screen', async () => ({ reserved: true, term: 'Council' }))
|
||||
|
||||
assert.equal(await moderation.rescreen('uo'), 1)
|
||||
assert.equal(db.teams.get(1).hidden, 1)
|
||||
assert.equal(db.teams.get(1).hidden_term, 'Council')
|
||||
})
|
||||
|
||||
test('a re-screen never re-hides a team staff have already ruled on', async () => {
|
||||
// unreviewedActive excludes them by definition — the stamp is the mechanism,
|
||||
// and without it an override would be undone on every sweep.
|
||||
patch(moderationDb, 'unreviewedActive', async () => [])
|
||||
patch(reservedNames, 'screen', async () => ({ reserved: true, term: 'admin' }))
|
||||
assert.equal(await moderation.rescreen('uo'), 0)
|
||||
})
|
||||
|
||||
test('a re-screen skips a team that is already hidden', async () => {
|
||||
patch(moderationDb, 'unreviewedActive', async () => [{ id: 1, name: 'Admin', hidden: 1 }])
|
||||
patch(reservedNames, 'screen', async () => { throw new Error('should not be screened again') })
|
||||
assert.equal(await moderation.rescreen('uo'), 0)
|
||||
})
|
||||
|
||||
test('a failing re-screen does not break the reconcile that called it', async () => {
|
||||
patch(moderationDb, 'unreviewedActive', async () => { throw new Error('db down') })
|
||||
assert.equal(await moderation.rescreen('uo'), 0)
|
||||
})
|
||||
|
||||
// ── The gate: moderator asks, admin applies ────────────────────────────────
|
||||
|
||||
test('a moderator un-hiding files a pending request and changes nothing public', async () => {
|
||||
const result = await moderation.requestOrApply({
|
||||
actor: mod, teamId: 1, action: 'unhide', reason: 'legitimate guild',
|
||||
})
|
||||
assert.equal(result.pending, true)
|
||||
assert.equal(db.teams.get(1).hidden, 1, 'nothing is published until an admin agrees')
|
||||
assert.equal(db.requests.get(1).status, 'pending')
|
||||
assert.deepEqual(actions(), ['team.moderation.request'])
|
||||
})
|
||||
|
||||
test('an admin un-hiding applies at once', async () => {
|
||||
const result = await moderation.requestOrApply({ actor: admin, teamId: 1, action: 'unhide' })
|
||||
assert.equal(result.pending, false)
|
||||
assert.equal(db.teams.get(1).hidden, 0)
|
||||
assert.equal(db.teams.get(1).name_reviewed_at, 'now', 'a human has now ruled on the name')
|
||||
assert.deepEqual(actions(), ['team.unhide'])
|
||||
})
|
||||
|
||||
test('an admin approving a moderator’s request publishes it', async () => {
|
||||
await moderation.requestOrApply({ actor: mod, teamId: 1, action: 'unhide', reason: 'legit' })
|
||||
assert.equal(db.teams.get(1).hidden, 1)
|
||||
|
||||
const result = await moderation.decide({ actor: admin, requestId: 1, status: 'approved' })
|
||||
assert.equal(result.applied, true)
|
||||
assert.equal(db.teams.get(1).hidden, 0)
|
||||
assert.deepEqual(actions(), ['team.moderation.request', 'team.unhide', 'team.moderation.approved'])
|
||||
})
|
||||
|
||||
test('a rejected request changes nothing but is kept', async () => {
|
||||
await moderation.requestOrApply({ actor: mod, teamId: 1, action: 'unhide' })
|
||||
const result = await moderation.decide({ actor: admin, requestId: 1, status: 'rejected', note: 'no' })
|
||||
|
||||
assert.equal(result.applied, false)
|
||||
assert.equal(db.teams.get(1).hidden, 1)
|
||||
assert.equal(db.requests.get(1).status, 'rejected', 'the record of a refusal is the part worth having')
|
||||
assert.equal(actions().includes('team.unhide'), false)
|
||||
})
|
||||
|
||||
test('a moderator may not decide a request', async () => {
|
||||
await moderation.requestOrApply({ actor: mod, teamId: 1, action: 'unhide' })
|
||||
const result = await moderation.decide({ actor: mod, requestId: 1, status: 'approved' })
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.status, 403)
|
||||
assert.equal(db.teams.get(1).hidden, 1)
|
||||
})
|
||||
|
||||
test('a request already decided cannot be decided again', async () => {
|
||||
await moderation.requestOrApply({ actor: mod, teamId: 1, action: 'unhide' })
|
||||
await moderation.decide({ actor: admin, requestId: 1, status: 'approved' })
|
||||
const second = await moderation.decide({ actor: admin, requestId: 1, status: 'rejected' })
|
||||
assert.equal(second.ok, false)
|
||||
assert.equal(second.status, 409)
|
||||
assert.equal(db.teams.get(1).hidden, 0, 'the first decision stands')
|
||||
})
|
||||
|
||||
test('two admins deciding at once — only one applies', async () => {
|
||||
// The row moves out of `pending` under a guard, and the effect follows only if
|
||||
// it actually moved. Without that, both would apply the action and the second
|
||||
// would overwrite the record of who decided it.
|
||||
await moderation.requestOrApply({ actor: mod, teamId: 1, action: 'unhide' })
|
||||
let applied = 0
|
||||
patch(moderationDb, 'setHidden', async () => { applied += 1 })
|
||||
|
||||
const [a, b] = await Promise.all([
|
||||
moderation.decide({ actor: admin, requestId: 1, status: 'approved' }),
|
||||
moderation.decide({ actor: { ...admin, id: 3, username: 'root2' }, requestId: 1, status: 'approved' }),
|
||||
])
|
||||
assert.equal([a.ok, b.ok].filter(Boolean).length, 1)
|
||||
assert.equal(applied, 1)
|
||||
})
|
||||
|
||||
test('an unknown request and an unknown team are refused, not guessed at', async () => {
|
||||
assert.equal((await moderation.decide({ actor: admin, requestId: 99, status: 'approved' })).status, 404)
|
||||
assert.equal((await moderation.requestOrApply({ actor: admin, teamId: 99, action: 'unhide' })).status, 404)
|
||||
})
|
||||
|
||||
test('an invalid decision status is refused', async () => {
|
||||
await moderation.requestOrApply({ actor: mod, teamId: 1, action: 'unhide' })
|
||||
assert.equal((await moderation.decide({ actor: admin, requestId: 1, status: 'maybe' })).status, 400)
|
||||
})
|
||||
|
||||
// ── The display-name override, through the same gate ───────────────────────
|
||||
|
||||
test('a display name set by an admin applies; by a moderator it waits', async () => {
|
||||
await moderation.requestOrApply({
|
||||
actor: admin, teamId: 1, action: 'display_name_override', payload: { displayName: 'The Old Guard' },
|
||||
})
|
||||
assert.equal(db.teams.get(1).display_name_override, 'The Old Guard')
|
||||
|
||||
db.teams.get(1).display_name_override = null
|
||||
await moderation.requestOrApply({
|
||||
actor: mod, teamId: 1, action: 'display_name_override', payload: { displayName: 'Sneaky' },
|
||||
})
|
||||
assert.equal(db.teams.get(1).display_name_override, null, 'free text into a public surface waits for an admin')
|
||||
})
|
||||
|
||||
test('an approved display-name request carries its payload through', async () => {
|
||||
await moderation.requestOrApply({
|
||||
actor: mod, teamId: 1, action: 'display_name_override', payload: { displayName: 'The Old Guard' },
|
||||
})
|
||||
await moderation.decide({ actor: admin, requestId: 1, status: 'approved' })
|
||||
assert.equal(db.teams.get(1).display_name_override, 'The Old Guard')
|
||||
})
|
||||
|
||||
test('a payload stored as a JSON string is parsed on approval', async () => {
|
||||
// The driver hands JSON columns back parsed on some versions and as a string on
|
||||
// others; an approval that silently applied `undefined` would be a data loss
|
||||
// that only shows up on one of them.
|
||||
await moderation.requestOrApply({
|
||||
actor: mod, teamId: 1, action: 'display_name_override', payload: { displayName: 'Kept' },
|
||||
})
|
||||
db.requests.get(1).payload = JSON.stringify({ displayName: 'Kept' })
|
||||
await moderation.decide({ actor: admin, requestId: 1, status: 'approved' })
|
||||
assert.equal(db.teams.get(1).display_name_override, 'Kept')
|
||||
})
|
||||
|
||||
test('clearing a display name is gated too', async () => {
|
||||
db.teams.get(1).display_name_override = 'Something'
|
||||
await moderation.requestOrApply({ actor: mod, teamId: 1, action: 'clear_display_name_override' })
|
||||
assert.equal(db.teams.get(1).display_name_override, 'Something')
|
||||
|
||||
await moderation.decide({ actor: admin, requestId: 1, status: 'approved' })
|
||||
assert.equal(db.teams.get(1).display_name_override, null)
|
||||
})
|
||||
|
||||
// ── Hiding is NOT gated ────────────────────────────────────────────────────
|
||||
|
||||
test('a moderator may hide immediately — suppression is always safe', async () => {
|
||||
db.teams.get(1).hidden = 0
|
||||
const result = await moderation.hide({ actor: mod, teamId: 1, reason: 'impersonation' })
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(db.teams.get(1).hidden, 1)
|
||||
assert.equal(db.teams.get(1).hidden_reason, 'staff')
|
||||
assert.deepEqual(actions(), ['team.hide'])
|
||||
assert.equal(db.requests.size, 0, 'withdrawing untrusted data must not wait for a second pair of eyes')
|
||||
})
|
||||
|
||||
// ── The gate's scope ───────────────────────────────────────────────────────
|
||||
|
||||
test('exactly three actions are gated', () => {
|
||||
assert.deepEqual(moderation.GATED_ACTIONS, ['unhide', 'display_name_override', 'clear_display_name_override'])
|
||||
})
|
||||
|
||||
test('an action outside the three is rejected rather than quietly gated', async () => {
|
||||
await assert.rejects(
|
||||
() => moderation.requestOrApply({ actor: mod, teamId: 1, action: 'archive' }),
|
||||
/not a gated action/,
|
||||
)
|
||||
})
|
||||
|
||||
test('every transition writes the audit log', async () => {
|
||||
await moderation.requestOrApply({ actor: mod, teamId: 1, action: 'unhide', reason: 'legit' })
|
||||
await moderation.decide({ actor: admin, requestId: 1, status: 'approved', note: 'checked' })
|
||||
|
||||
assert.equal(logged.length, 3)
|
||||
assert.match(logged[0].detail, /mod1 \(#2\) requested "unhide" on team "Admin" \(#1\): "legit"/)
|
||||
assert.match(logged[2].detail, /root \(#1\) approved request #1/)
|
||||
assert.match(logged[2].detail, /asked by mod1/)
|
||||
})
|
||||
|
||||
test('the audit trail survives the requester’s account being deleted', async () => {
|
||||
await moderation.requestOrApply({ actor: mod, teamId: 1, action: 'unhide' })
|
||||
// §2.10: requested_by goes SET NULL and the username snapshot is what keeps the
|
||||
// record readable.
|
||||
db.requests.get(1).requested_username = null
|
||||
await moderation.decide({ actor: admin, requestId: 1, status: 'rejected' })
|
||||
assert.match(logged[logged.length - 1].detail, /asked by a deleted user/)
|
||||
})
|
||||
289
server/test/teamProvider.test.js
Normal file
289
server/test/teamProvider.test.js
Normal file
@@ -0,0 +1,289 @@
|
||||
// The Team provider registration and the guarded call path
|
||||
// (docs/website/TEAMS.md §2.3).
|
||||
//
|
||||
// Almost every test here is invariant 1 asked a different way: **module
|
||||
// unavailability is staleness, never emptiness.** The value of this file is that
|
||||
// it enumerates the shapes a broken provider can produce and asserts that none of
|
||||
// them arrives at the reconciler looking like authoritative data.
|
||||
const { test, beforeEach, afterEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const registries = require('../src/modules/registries')
|
||||
const teamProvider = require('../src/model/teams/teamProvider')
|
||||
|
||||
// Register a provider the way a module does: stage, then commit.
|
||||
function register(owner, provider) {
|
||||
const api = registries.stage(owner)
|
||||
api.registerTeamProvider(provider)
|
||||
registries.apply(api.staged)
|
||||
}
|
||||
|
||||
const ok = () => ({
|
||||
getTeams: async () => ({ ok: true, teams: [{ externalId: 'g1', name: 'The Silver Hand' }] }),
|
||||
getTeamMembers: async () => ({ ok: true, members: [{ memberKey: '0x1', displayName: 'Aldric' }] }),
|
||||
getTeamLeaders: async () => ({ ok: true, leaders: ['0x1'] }),
|
||||
})
|
||||
|
||||
beforeEach(() => registries._reset())
|
||||
afterEach(() => registries._reset())
|
||||
|
||||
// ── Registration ───────────────────────────────────────────────────────────
|
||||
|
||||
test('a provider is readable only after apply(), not at stage time', () => {
|
||||
const api = registries.stage('uo')
|
||||
api.registerTeamProvider(ok())
|
||||
assert.equal(registries.hasTeamProvider(), false, 'staging must not publish')
|
||||
|
||||
registries.apply(api.staged)
|
||||
assert.equal(registries.hasTeamProvider(), true)
|
||||
assert.equal(registries.registeredTeamProvider().owner, 'uo')
|
||||
})
|
||||
|
||||
test('all three methods are required', () => {
|
||||
const api = registries.stage('uo')
|
||||
for (const missing of ['getTeams', 'getTeamMembers', 'getTeamLeaders']) {
|
||||
const provider = ok()
|
||||
delete provider[missing]
|
||||
assert.throws(() => api.registerTeamProvider(provider), new RegExp(`${missing}\\(\\) is missing`))
|
||||
}
|
||||
// A non-function is the same failure, and is the likelier typo.
|
||||
assert.throws(() => api.registerTeamProvider({ ...ok(), getTeams: 'yes' }), /getTeams\(\) is missing or not a function/)
|
||||
})
|
||||
|
||||
test('a second provider is a collision naming the module that holds it', () => {
|
||||
register('uo', ok())
|
||||
const second = registries.stage('other')
|
||||
second.registerTeamProvider(ok())
|
||||
assert.throws(() => registries.apply(second.staged), /already registered by "uo"/)
|
||||
// The first registration is untouched by the rejected second.
|
||||
assert.equal(registries.registeredTeamProvider().owner, 'uo')
|
||||
})
|
||||
|
||||
test('one module registering twice in one batch is rejected', () => {
|
||||
const api = registries.stage('uo')
|
||||
api.registerTeamProvider(ok())
|
||||
api.registerTeamProvider(ok())
|
||||
assert.throws(() => registries.apply(api.staged), /more than one team provider/)
|
||||
assert.equal(registries.hasTeamProvider(), false, 'the whole batch is refused')
|
||||
})
|
||||
|
||||
test('a rejected batch leaves no provider behind, even when its other claims are fine', () => {
|
||||
const api = registries.stage('uo')
|
||||
api.registerTeamProvider(ok())
|
||||
api.registerNotificationStreams([{ id: 'uo.thing', label: 'Thing' }])
|
||||
api.registerNotificationStreams([{ id: 'uo.thing', label: 'Thing' }]) // duplicate
|
||||
assert.throws(() => registries.apply(api.staged))
|
||||
assert.equal(registries.hasTeamProvider(), false, 'validate-then-commit covers the provider too')
|
||||
})
|
||||
|
||||
// ── The call path: every failure shape becomes { ok: false } ───────────────
|
||||
|
||||
test('no registered provider is a refusal, not an empty answer', async () => {
|
||||
const answer = await teamProvider.getTeams()
|
||||
assert.equal(answer.ok, false)
|
||||
assert.equal(answer.teams, undefined, 'a refusal never carries a teams array')
|
||||
assert.equal(teamProvider.providerModuleId(), null)
|
||||
})
|
||||
|
||||
test('a provider that throws is a refusal', async () => {
|
||||
register('uo', { ...ok(), getTeams: async () => { throw new Error('sidecar unreachable') } })
|
||||
const answer = await teamProvider.getTeams()
|
||||
assert.equal(answer.ok, false)
|
||||
assert.match(answer.reason, /sidecar unreachable/)
|
||||
assert.equal(answer.teams, undefined)
|
||||
})
|
||||
|
||||
test('a provider that throws SYNCHRONOUSLY is a refusal too', async () => {
|
||||
register('uo', { ...ok(), getTeams: () => { throw new Error('boom') } })
|
||||
const answer = await teamProvider.getTeams()
|
||||
assert.equal(answer.ok, false)
|
||||
assert.match(answer.reason, /boom/)
|
||||
})
|
||||
|
||||
test('a deliberate { ok: false } keeps its reason for team_sync_state', async () => {
|
||||
register('uo', { ...ok(), getTeams: async () => ({ ok: false, reason: 'cache cold' }) })
|
||||
assert.deepEqual(await teamProvider.getTeams(), { ok: false, reason: 'cache cold' })
|
||||
})
|
||||
|
||||
test('a missing ok field is not read as authority', async () => {
|
||||
register('uo', { ...ok(), getTeams: async () => ({ teams: [] }) })
|
||||
const answer = await teamProvider.getTeams()
|
||||
assert.equal(answer.ok, false, 'a forgotten field must not become an authoritative empty list')
|
||||
})
|
||||
|
||||
test('a bare array — the shape the envelope exists to outlaw — is a refusal', async () => {
|
||||
register('uo', { ...ok(), getTeams: async () => [] })
|
||||
const answer = await teamProvider.getTeams()
|
||||
assert.equal(answer.ok, false)
|
||||
assert.match(answer.reason, /not an envelope/)
|
||||
})
|
||||
|
||||
test('null, undefined and a string are all refusals', async () => {
|
||||
for (const bad of [null, undefined, 'ok', 42]) {
|
||||
register('uo', { ...ok(), getTeams: async () => bad })
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
assert.equal((await teamProvider.getTeams()).ok, false, `${String(bad)} must not be authoritative`)
|
||||
registries._reset()
|
||||
}
|
||||
})
|
||||
|
||||
test('ok:true with no teams array is a refusal, not zero teams', async () => {
|
||||
register('uo', { ...ok(), getTeams: async () => ({ ok: true }) })
|
||||
const answer = await teamProvider.getTeams()
|
||||
assert.equal(answer.ok, false)
|
||||
assert.match(answer.reason, /no teams array/)
|
||||
})
|
||||
|
||||
test('an ok answer with a genuinely empty list stays ok — §2.4 decides what to do with it', async () => {
|
||||
register('uo', { ...ok(), getTeams: async () => ({ ok: true, teams: [] }) })
|
||||
const answer = await teamProvider.getTeams()
|
||||
assert.equal(answer.ok, true, 'this file must not second-guess an authoritative empty answer')
|
||||
assert.deepEqual(answer.teams, [])
|
||||
})
|
||||
|
||||
// ── Malformed rows fail the call rather than being salvaged ────────────────
|
||||
|
||||
test('a team with no externalId fails the whole call', async () => {
|
||||
register('uo', { ...ok(), getTeams: async () => ({ ok: true, teams: [{ name: 'Nameless' }] }) })
|
||||
const answer = await teamProvider.getTeams()
|
||||
assert.equal(answer.ok, false)
|
||||
assert.match(answer.reason, /no externalId/)
|
||||
})
|
||||
|
||||
test('a team with no name fails the whole call', async () => {
|
||||
register('uo', { ...ok(), getTeams: async () => ({ ok: true, teams: [{ externalId: 'g1', name: ' ' }] }) })
|
||||
assert.match((await teamProvider.getTeams()).reason, /"g1" has no name/)
|
||||
})
|
||||
|
||||
test('one unreadable member refuses the roster rather than dropping the member', async () => {
|
||||
// Dropping it would be indistinguishable, downstream, from the member leaving —
|
||||
// the sync would mark them departed on the strength of a malformed payload.
|
||||
register('uo', {
|
||||
...ok(),
|
||||
getTeamMembers: async () => ({ ok: true, members: [{ memberKey: '0x1' }, { displayName: 'ghost' }] }),
|
||||
})
|
||||
const answer = await teamProvider.getTeamMembers('g1')
|
||||
assert.equal(answer.ok, false)
|
||||
assert.equal(answer.members, undefined)
|
||||
})
|
||||
|
||||
test('a duplicated memberKey is refused rather than collapsed', async () => {
|
||||
register('uo', {
|
||||
...ok(),
|
||||
getTeamMembers: async () => ({ ok: true, members: [{ memberKey: '0x1' }, { memberKey: '0x1' }] }),
|
||||
})
|
||||
assert.match((await teamProvider.getTeamMembers('g1')).reason, /appears twice/)
|
||||
})
|
||||
|
||||
// ── Normalisation of a good answer ─────────────────────────────────────────
|
||||
|
||||
test('team fields are trimmed, and meta is passed through opaquely', async () => {
|
||||
register('uo', {
|
||||
...ok(),
|
||||
getTeams: async () => ({
|
||||
ok: true,
|
||||
teams: [{ externalId: ' g1 ', name: ' The Silver Hand ', abbr: ' TSH ', meta: { crest: 7 } }],
|
||||
}),
|
||||
})
|
||||
const { teams } = await teamProvider.getTeams()
|
||||
assert.deepEqual(teams, [{ externalId: 'g1', name: 'The Silver Hand', abbr: 'TSH', meta: { crest: 7 } }])
|
||||
})
|
||||
|
||||
test('a non-object meta is dropped rather than stored as a scalar', async () => {
|
||||
register('uo', {
|
||||
...ok(),
|
||||
getTeams: async () => ({ ok: true, teams: [{ externalId: 'g1', name: 'X', meta: 'crest' }] }),
|
||||
})
|
||||
assert.equal((await teamProvider.getTeams()).teams[0].meta, null)
|
||||
})
|
||||
|
||||
test('member booleans are coerced and userId is accepted only as a positive integer', async () => {
|
||||
register('uo', {
|
||||
...ok(),
|
||||
getTeamMembers: async () => ({
|
||||
ok: true,
|
||||
members: [
|
||||
{ memberKey: '0x1', displayName: 'Aldric', rankLabel: 'Warlord', leader: 1, online: 'yes', userId: 7 },
|
||||
{ memberKey: '0x2', userId: 0 },
|
||||
{ memberKey: '0x3', userId: '7' },
|
||||
{ memberKey: '0x4', userId: 1.5 },
|
||||
],
|
||||
}),
|
||||
})
|
||||
const { members } = await teamProvider.getTeamMembers('g1')
|
||||
assert.equal(members[0].leader, true)
|
||||
assert.equal(members[0].online, true)
|
||||
assert.equal(members[0].userId, 7)
|
||||
assert.equal(members[1].userId, null, '0 is not a user id')
|
||||
assert.equal(members[2].userId, null, 'a numeric string is not a resolved link')
|
||||
assert.equal(members[3].userId, null)
|
||||
// Absent optional fields become null rather than undefined, so a column write
|
||||
// does not depend on the module having spelled the key.
|
||||
assert.equal(members[1].displayName, null)
|
||||
assert.equal(members[1].rankLabel, null)
|
||||
})
|
||||
|
||||
test('complete defaults to true and is honoured when false', async () => {
|
||||
register('uo', ok())
|
||||
assert.equal((await teamProvider.getTeams()).complete, true)
|
||||
registries._reset()
|
||||
|
||||
register('uo', { ...ok(), getTeams: async () => ({ ok: true, complete: false, teams: [] }) })
|
||||
assert.equal((await teamProvider.getTeams()).complete, false)
|
||||
})
|
||||
|
||||
test('duplicate leaders are collapsed and blanks refused', async () => {
|
||||
register('uo', { ...ok(), getTeamLeaders: async () => ({ ok: true, leaders: ['0x1', '0x1', ' 0x2 '] }) })
|
||||
assert.deepEqual((await teamProvider.getTeamLeaders('g1')).leaders, ['0x1', '0x2'])
|
||||
registries._reset()
|
||||
|
||||
register('uo', { ...ok(), getTeamLeaders: async () => ({ ok: true, leaders: ['0x1', ''] }) })
|
||||
assert.equal((await teamProvider.getTeamLeaders('g1')).ok, false)
|
||||
})
|
||||
|
||||
test('the external id is passed through to the module unchanged', async () => {
|
||||
const seen = []
|
||||
register('uo', { ...ok(), getTeamMembers: async (id) => { seen.push(id); return { ok: true, members: [] } } })
|
||||
await teamProvider.getTeamMembers('g-42')
|
||||
assert.deepEqual(seen, ['g-42'])
|
||||
})
|
||||
|
||||
test('providerModuleId names the registrant, which is what sync state is keyed on', async () => {
|
||||
register('uo', ok())
|
||||
assert.equal(teamProvider.providerModuleId(), 'uo')
|
||||
})
|
||||
|
||||
// ── The timeout ────────────────────────────────────────────────────────────
|
||||
|
||||
test('a provider that never answers becomes a refusal at the deadline', async (t) => {
|
||||
// Mocked timers rather than a real ten-second wait: this exercises the
|
||||
// production path exactly — the same setTimeout, the same deadline — without
|
||||
// putting ten seconds into every CI run.
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] })
|
||||
register('uo', { ...ok(), getTeams: () => new Promise(() => {}) })
|
||||
|
||||
const pending = teamProvider.getTeams()
|
||||
t.mock.timers.tick(teamProvider.CALL_TIMEOUT_MS)
|
||||
|
||||
const answer = await pending
|
||||
assert.equal(answer.ok, false)
|
||||
assert.match(answer.reason, /did not answer within 10000ms/)
|
||||
assert.equal(answer.teams, undefined, 'a hung module never produces data')
|
||||
})
|
||||
|
||||
test('a hung call does not hold the process open until its deadline', async () => {
|
||||
// The timer is unreffed, so a call left pending at shutdown cannot keep the
|
||||
// event loop alive. Asserted directly, because the symptom — a test FILE that
|
||||
// passes in milliseconds and then sits for ten seconds — is invisible in a
|
||||
// green summary.
|
||||
register('uo', { ...ok(), getTeams: () => new Promise(() => {}) })
|
||||
const before = process.getActiveResourcesInfo().filter((r) => r === 'Timeout').length
|
||||
teamProvider.getTeams()
|
||||
await new Promise((resolve) => { setImmediate(resolve) })
|
||||
const after = process.getActiveResourcesInfo().filter((r) => r === 'Timeout').length
|
||||
assert.equal(after, before, 'the deadline timer must not count as an active resource')
|
||||
})
|
||||
|
||||
test('the budget is the documented ten seconds', () => {
|
||||
assert.equal(teamProvider.CALL_TIMEOUT_MS, 10_000)
|
||||
})
|
||||
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')
|
||||
})
|
||||
})
|
||||
804
server/test/teamSync.test.js
Normal file
804
server/test/teamSync.test.js
Normal file
@@ -0,0 +1,804 @@
|
||||
// The reconciler and its four refusal gates (docs/website/TEAMS.md §2.4).
|
||||
//
|
||||
// The db layer is stubbed and an in-memory projection stands in for the tables,
|
||||
// so these are assertions about the ALGORITHM: which answers are applied, which
|
||||
// are refused, and what is left untouched when one is refused. The gates are the
|
||||
// reason the file exists — every one of them is invariant 1 in a different
|
||||
// costume, and each is easy to "simplify" away by someone who has not seen what
|
||||
// an empty answer during a cold start does to a site full of rosters.
|
||||
const { test, beforeEach, afterEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const registries = require('../src/modules/registries')
|
||||
const teamsDb = require('../src/model/teams/teams.db')
|
||||
const moderation = require('../src/model/teams/teamModeration.model')
|
||||
const settings = require('../src/model/settings/settings.model')
|
||||
const teamSync = require('../src/model/teams/teamSync.model')
|
||||
|
||||
// ── An in-memory stand-in for the four tables ──────────────────────────────
|
||||
|
||||
let store
|
||||
const saved = new Map()
|
||||
|
||||
function patch(mod, name, fn) {
|
||||
if (!saved.has(mod)) saved.set(mod, new Map())
|
||||
if (!saved.get(mod).has(name)) saved.get(mod).set(name, mod[name])
|
||||
mod[name] = fn
|
||||
}
|
||||
|
||||
function restore() {
|
||||
for (const [mod, names] of saved) for (const [name, fn] of names) mod[name] = fn
|
||||
saved.clear()
|
||||
}
|
||||
|
||||
function freshStore() {
|
||||
return {
|
||||
teams: [], // { id, module_id, external_id, name, abbr, slug, status, ... }
|
||||
members: new Map(), // teamId -> Map(memberKey -> row)
|
||||
sync: new Map(), // moduleId -> row
|
||||
nextId: 1,
|
||||
}
|
||||
}
|
||||
|
||||
function membersOf(teamId) {
|
||||
if (!store.members.has(teamId)) store.members.set(teamId, new Map())
|
||||
return store.members.get(teamId)
|
||||
}
|
||||
|
||||
function stubDb() {
|
||||
patch(teamsDb, 'activeByModule', async (moduleId) =>
|
||||
store.teams.filter((t) => t.module_id === moduleId && t.status === 'active'))
|
||||
|
||||
patch(teamsDb, 'findActive', async (moduleId, externalId) =>
|
||||
store.teams.find((t) => t.module_id === moduleId && t.external_id === externalId && t.status === 'active'))
|
||||
|
||||
patch(teamsDb, 'findById', async (id) => store.teams.find((t) => t.id === id))
|
||||
|
||||
patch(teamsDb, 'slugsLike', async (base) =>
|
||||
store.teams.filter((t) => t.slug === base || t.slug.startsWith(`${base}-`)).map((t) => t.slug))
|
||||
|
||||
patch(teamsDb, 'insertTeam', async (row) => {
|
||||
const id = store.nextId++
|
||||
store.teams.push({
|
||||
id,
|
||||
module_id: row.moduleId,
|
||||
external_id: row.externalId,
|
||||
name: row.name,
|
||||
abbr: row.abbr ?? null,
|
||||
slug: row.slug,
|
||||
meta: row.meta ?? null,
|
||||
status: 'active',
|
||||
hidden: row.hidden ? 1 : 0,
|
||||
hidden_reason: row.hiddenReason || null,
|
||||
hidden_term: row.hiddenTerm || null,
|
||||
members_empty_since: null,
|
||||
roster_synced_at: null,
|
||||
succeeded_by: null,
|
||||
member_count: 0,
|
||||
linked_count: 0,
|
||||
online_count: 0,
|
||||
})
|
||||
return id
|
||||
})
|
||||
|
||||
patch(teamsDb, 'updateTeam', async (id, { abbr, meta }) => {
|
||||
const t = store.teams.find((x) => x.id === id)
|
||||
if (t) Object.assign(t, { abbr, meta })
|
||||
})
|
||||
|
||||
patch(teamsDb, 'archiveTeam', async (id, reason, succeededBy = null) => {
|
||||
const t = store.teams.find((x) => x.id === id && x.status === 'active')
|
||||
if (t) Object.assign(t, { status: 'archived', archived_reason: reason, succeeded_by: succeededBy })
|
||||
})
|
||||
|
||||
patch(teamsDb, 'recount', async (teamId) => {
|
||||
const t = store.teams.find((x) => x.id === teamId)
|
||||
if (!t) return
|
||||
const rows = [...membersOf(teamId).values()].filter((m) => m.status === 'active')
|
||||
t.member_count = rows.length
|
||||
t.linked_count = rows.filter((m) => m.user_id != null).length
|
||||
t.online_count = rows.filter((m) => m.online).length
|
||||
})
|
||||
|
||||
patch(teamsDb, 'markRosterSynced', async (teamId) => {
|
||||
const t = store.teams.find((x) => x.id === teamId)
|
||||
if (t) t.roster_synced_at = new Date()
|
||||
})
|
||||
|
||||
patch(teamsDb, 'setMembersEmptySince', async (teamId, since) => {
|
||||
const t = store.teams.find((x) => x.id === teamId)
|
||||
if (t) t.members_empty_since = since
|
||||
})
|
||||
|
||||
patch(teamsDb, 'memberKeys', async (teamId) =>
|
||||
[...membersOf(teamId).values()].filter((m) => m.status === 'active').map((m) => m.member_key))
|
||||
|
||||
patch(teamsDb, 'upsertMember', async (m) => {
|
||||
const existing = membersOf(m.teamId).get(m.memberKey)
|
||||
membersOf(m.teamId).set(m.memberKey, {
|
||||
team_id: m.teamId,
|
||||
member_key: m.memberKey,
|
||||
display_name: m.displayName ?? null,
|
||||
user_id: m.userId ?? null,
|
||||
// Insert-only, mirroring the ON DUPLICATE KEY UPDATE clause that omits it:
|
||||
// leadership is getTeamLeaders()'s answer, not the roster's.
|
||||
is_leader: existing ? existing.is_leader : (m.isLeader ? 1 : 0),
|
||||
rank_label: m.rankLabel ?? null,
|
||||
online: m.online ? 1 : 0,
|
||||
status: 'active',
|
||||
first_seen_at: existing ? existing.first_seen_at : 'first',
|
||||
})
|
||||
})
|
||||
|
||||
patch(teamsDb, 'markDeparted', async (teamId, keys) => {
|
||||
for (const key of keys) {
|
||||
const row = membersOf(teamId).get(key)
|
||||
if (row && row.status === 'active') Object.assign(row, { status: 'departed', online: 0 })
|
||||
}
|
||||
})
|
||||
|
||||
patch(teamsDb, 'setLeaders', async (teamId, leaderKeys) => {
|
||||
for (const row of membersOf(teamId).values()) row.is_leader = leaderKeys.includes(row.member_key) ? 1 : 0
|
||||
})
|
||||
|
||||
patch(teamsDb, 'setMemberLeader', async (teamId, key, isLeader) => {
|
||||
const row = membersOf(teamId).get(key)
|
||||
if (row) row.is_leader = isLeader ? 1 : 0
|
||||
})
|
||||
|
||||
patch(teamsDb, 'syncState', async (moduleId) => store.sync.get(moduleId))
|
||||
patch(teamsDb, 'recordAttempt', async (moduleId) => {
|
||||
const s = store.sync.get(moduleId) || { module_id: moduleId, consecutive_failures: 0 }
|
||||
s.last_attempt_at = new Date()
|
||||
store.sync.set(moduleId, s)
|
||||
})
|
||||
patch(teamsDb, 'recordFailure', async (moduleId, error) => {
|
||||
const s = store.sync.get(moduleId) || { module_id: moduleId, consecutive_failures: 0 }
|
||||
s.consecutive_failures += 1
|
||||
s.last_error = error
|
||||
store.sync.set(moduleId, s)
|
||||
})
|
||||
patch(teamsDb, 'recordSuccess', async (moduleId) => {
|
||||
const s = store.sync.get(moduleId) || { module_id: moduleId, consecutive_failures: 0 }
|
||||
s.consecutive_failures = 0
|
||||
s.last_error = null
|
||||
s.last_success_at = new Date()
|
||||
store.sync.set(moduleId, s)
|
||||
})
|
||||
patch(teamsDb, 'setPendingEmpty', async (moduleId, since) => {
|
||||
const s = store.sync.get(moduleId) || { module_id: moduleId, consecutive_failures: 0 }
|
||||
s.pending_empty_since = since
|
||||
store.sync.set(moduleId, s)
|
||||
})
|
||||
|
||||
// Reserved-name screening is its own unit (teamModeration.test.js). Stubbed
|
||||
// here so these tests stay about the reconciler — and because the real calls
|
||||
// read settings, which means a live database connection this suite must never
|
||||
// make. `screened` records that the reconciler asked, which is the integration
|
||||
// point worth asserting from this side.
|
||||
store.screened = []
|
||||
patch(moderation, 'screenForCreate', async (name) => {
|
||||
store.screened.push(name)
|
||||
return { hidden: false }
|
||||
})
|
||||
patch(moderation, 'rescreen', async () => 0)
|
||||
}
|
||||
|
||||
// A provider whose answers the test controls. Defaults are authoritative and
|
||||
// well-formed, so each test only states the part it is about.
|
||||
function provide(overrides = {}) {
|
||||
const provider = {
|
||||
getTeams: async () => ({ ok: true, teams: [] }),
|
||||
getTeamMembers: async () => ({ ok: true, members: [] }),
|
||||
getTeamLeaders: async () => ({ ok: true, leaders: [] }),
|
||||
...overrides,
|
||||
}
|
||||
const api = registries.stage('uo')
|
||||
api.registerTeamProvider(provider)
|
||||
registries.apply(api.staged)
|
||||
return provider
|
||||
}
|
||||
|
||||
const team = (externalId, name, extra = {}) => ({ externalId, name, abbr: null, meta: null, ...extra })
|
||||
const member = (memberKey, extra = {}) => ({
|
||||
memberKey, displayName: memberKey, rankLabel: null, leader: false, online: false, userId: null, ...extra,
|
||||
})
|
||||
|
||||
const activeTeams = () => store.teams.filter((t) => t.status === 'active')
|
||||
const activeMembers = (teamId) => [...membersOf(teamId).values()].filter((m) => m.status === 'active')
|
||||
|
||||
beforeEach(() => {
|
||||
store = freshStore()
|
||||
registries._reset()
|
||||
teamSync._reset()
|
||||
stubDb()
|
||||
// The settings read is the only other database touch on this path.
|
||||
patch(settings, 'get', async () => null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
teamSync._reset()
|
||||
registries._reset()
|
||||
restore()
|
||||
})
|
||||
|
||||
// ── Gate 1: a failed getTeams() touches nothing ────────────────────────────
|
||||
|
||||
test('gate 1 — a provider that cannot answer leaves every row untouched', async () => {
|
||||
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'The Silver Hand')] }) })
|
||||
await teamSync.reconcileNow('setup')
|
||||
// Compared as JSON on both sides: the rows carry Date objects, and a snapshot
|
||||
// taken through JSON would otherwise "differ" from the live rows purely by
|
||||
// having stringified them.
|
||||
const before = JSON.stringify(store.teams)
|
||||
assert.equal(store.teams.length, 1)
|
||||
|
||||
registries._reset()
|
||||
provide({ getTeams: async () => ({ ok: false, reason: 'sidecar unreachable' }) })
|
||||
const result = await teamSync.reconcileNow('test')
|
||||
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(JSON.stringify(store.teams), before, 'not a single row may change')
|
||||
assert.equal(store.sync.get('uo').consecutive_failures, 1)
|
||||
assert.equal(store.sync.get('uo').last_error, 'sidecar unreachable')
|
||||
})
|
||||
|
||||
test('gate 1 — a hung or throwing provider is the same refusal, not an empty list', async () => {
|
||||
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) })
|
||||
await teamSync.reconcileNow('setup')
|
||||
|
||||
registries._reset()
|
||||
provide({ getTeams: async () => { throw new Error('EPIPE') } })
|
||||
await teamSync.reconcileNow('test')
|
||||
assert.equal(activeTeams().length, 1, 'a thrown error must never read as "no teams"')
|
||||
})
|
||||
|
||||
test('failures accumulate and a success clears them', async () => {
|
||||
provide({ getTeams: async () => ({ ok: false, reason: 'down' }) })
|
||||
await teamSync.reconcileNow('a')
|
||||
await teamSync.reconcileNow('b')
|
||||
assert.equal(store.sync.get('uo').consecutive_failures, 2)
|
||||
|
||||
registries._reset()
|
||||
provide()
|
||||
await teamSync.reconcileNow('c')
|
||||
assert.equal(store.sync.get('uo').consecutive_failures, 0)
|
||||
assert.equal(store.sync.get('uo').last_error, null)
|
||||
})
|
||||
|
||||
// ── Gate 2: an authoritative empty list is quarantined ─────────────────────
|
||||
|
||||
test('gate 2 — the first empty answer archives nothing', async () => {
|
||||
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A'), team('g2', 'B')] }) })
|
||||
await teamSync.reconcileNow('setup')
|
||||
assert.equal(activeTeams().length, 2)
|
||||
|
||||
registries._reset()
|
||||
provide({ getTeams: async () => ({ ok: true, teams: [] }) })
|
||||
const result = await teamSync.reconcileNow('test')
|
||||
|
||||
assert.equal(result.quarantined, true)
|
||||
assert.equal(activeTeams().length, 2, 'a cold start must not empty the site')
|
||||
assert.ok(store.sync.get('uo').pending_empty_since, 'the answer is remembered')
|
||||
})
|
||||
|
||||
test('gate 2 — a second empty answer, an interval later, is applied', async () => {
|
||||
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) })
|
||||
await teamSync.reconcileNow('setup')
|
||||
|
||||
registries._reset()
|
||||
provide({ getTeams: async () => ({ ok: true, teams: [] }) })
|
||||
await teamSync.reconcileNow('first empty')
|
||||
// Age the quarantine past one full interval.
|
||||
store.sync.get('uo').pending_empty_since = new Date(Date.now() - (teamSync.DEFAULT_INTERVAL_S + 1) * 1000)
|
||||
|
||||
const result = await teamSync.reconcileNow('second empty')
|
||||
assert.equal(result.archived, 1, 'every team on the shard really did disband')
|
||||
assert.equal(activeTeams().length, 0)
|
||||
assert.equal(store.teams[0].archived_reason, 'disbanded')
|
||||
})
|
||||
|
||||
test('gate 2 — a second empty answer TOO SOON is still quarantined', async () => {
|
||||
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) })
|
||||
await teamSync.reconcileNow('setup')
|
||||
|
||||
registries._reset()
|
||||
provide({ getTeams: async () => ({ ok: true, teams: [] }) })
|
||||
await teamSync.reconcileNow('first')
|
||||
const result = await teamSync.reconcileNow('second, immediately')
|
||||
|
||||
assert.equal(result.quarantined, true, 'two answers a second apart are one cold start, not two')
|
||||
assert.equal(activeTeams().length, 1)
|
||||
})
|
||||
|
||||
test('gate 2 — any non-empty answer clears the quarantine', async () => {
|
||||
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) })
|
||||
await teamSync.reconcileNow('setup')
|
||||
|
||||
registries._reset()
|
||||
provide({ getTeams: async () => ({ ok: true, teams: [] }) })
|
||||
await teamSync.reconcileNow('empty')
|
||||
assert.ok(store.sync.get('uo').pending_empty_since)
|
||||
|
||||
registries._reset()
|
||||
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) })
|
||||
await teamSync.reconcileNow('recovered')
|
||||
assert.equal(store.sync.get('uo').pending_empty_since, null)
|
||||
})
|
||||
|
||||
test('gate 2 — an empty list with nothing held is not a quarantine, just nothing to do', async () => {
|
||||
provide({ getTeams: async () => ({ ok: true, teams: [] }) })
|
||||
const result = await teamSync.reconcileNow('test')
|
||||
assert.equal(result.ok, true)
|
||||
assert.notEqual(result.quarantined, true)
|
||||
})
|
||||
|
||||
test('an incomplete answer never removes, so an empty partial list is harmless', async () => {
|
||||
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) })
|
||||
await teamSync.reconcileNow('setup')
|
||||
|
||||
registries._reset()
|
||||
provide({ getTeams: async () => ({ ok: true, complete: false, teams: [] }) })
|
||||
const result = await teamSync.reconcileNow('partial')
|
||||
assert.equal(result.archived, 0)
|
||||
assert.equal(activeTeams().length, 1)
|
||||
assert.ok(!store.sync.get('uo').pending_empty_since, 'no quarantine needed — nothing was at risk')
|
||||
})
|
||||
|
||||
// ── Gate 3: one Team's unanswerable roster ─────────────────────────────────
|
||||
|
||||
test('gate 3 — a refused roster leaves that team alone and the others sync', async () => {
|
||||
provide({
|
||||
getTeams: async () => ({ ok: true, teams: [team('g1', 'A'), team('g2', 'B')] }),
|
||||
getTeamMembers: async (id) => (id === 'g1'
|
||||
? { ok: true, members: [member('0x1'), member('0x2')] }
|
||||
: { ok: true, members: [member('0x9')] }),
|
||||
})
|
||||
await teamSync.reconcileNow('setup')
|
||||
assert.equal(activeMembers(1).length, 2)
|
||||
assert.equal(activeMembers(2).length, 1)
|
||||
|
||||
registries._reset()
|
||||
provide({
|
||||
getTeams: async () => ({ ok: true, teams: [team('g1', 'A'), team('g2', 'B')] }),
|
||||
getTeamMembers: async (id) => (id === 'g1'
|
||||
? { ok: false, reason: 'roster unavailable' }
|
||||
: { ok: true, members: [member('0x9'), member('0xA')] }),
|
||||
})
|
||||
const result = await teamSync.reconcileNow('test')
|
||||
|
||||
assert.equal(activeMembers(1).length, 2, "g1's roster is untouched, not emptied")
|
||||
assert.equal(activeMembers(2).length, 2, "g2 syncs normally — one team's problem is its own")
|
||||
assert.equal(result.rosters, 1, 'only one roster was applied')
|
||||
})
|
||||
|
||||
test('gate 3 — a refused roster does not bump that team’s freshness stamp', async () => {
|
||||
provide({
|
||||
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
|
||||
getTeamMembers: async () => ({ ok: true, members: [member('0x1')] }),
|
||||
})
|
||||
await teamSync.reconcileNow('setup')
|
||||
const syncedAt = store.teams[0].roster_synced_at
|
||||
assert.ok(syncedAt)
|
||||
|
||||
registries._reset()
|
||||
provide({
|
||||
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
|
||||
getTeamMembers: async () => ({ ok: false, reason: 'nope' }),
|
||||
})
|
||||
await teamSync.reconcileNow('test')
|
||||
assert.equal(store.teams[0].roster_synced_at, syncedAt, 'stale must show as stale, not as just-synced')
|
||||
})
|
||||
|
||||
test('leadership is a separate answer — a refused one does not demote anybody', async () => {
|
||||
provide({
|
||||
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
|
||||
getTeamMembers: async () => ({ ok: true, members: [member('0x1'), member('0x2')] }),
|
||||
getTeamLeaders: async () => ({ ok: true, leaders: ['0x1'] }),
|
||||
})
|
||||
await teamSync.reconcileNow('setup')
|
||||
assert.equal(membersOf(1).get('0x1').is_leader, 1)
|
||||
|
||||
registries._reset()
|
||||
provide({
|
||||
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
|
||||
getTeamMembers: async () => ({ ok: true, members: [member('0x1'), member('0x2')] }),
|
||||
getTeamLeaders: async () => ({ ok: false, reason: 'cannot say' }),
|
||||
})
|
||||
await teamSync.reconcileNow('test')
|
||||
assert.equal(membersOf(1).get('0x1').is_leader, 1, 'an unanswerable question is not the answer "nobody"')
|
||||
})
|
||||
|
||||
test('the roster seeds is_leader on a new row but never overwrites it afterwards', async () => {
|
||||
// Two writers for one column is how a refused leadership answer becomes a
|
||||
// silent demotion: the roster would write `leader: false` before the
|
||||
// authoritative call was even made. Seeding on insert keeps a Team from being
|
||||
// leaderless while getTeamLeaders() is failing.
|
||||
provide({
|
||||
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
|
||||
getTeamMembers: async () => ({ ok: true, members: [member('0x1', { leader: true })] }),
|
||||
getTeamLeaders: async () => ({ ok: false, reason: 'cannot say' }),
|
||||
})
|
||||
await teamSync.reconcileNow('first sync, leadership unanswerable')
|
||||
assert.equal(membersOf(1).get('0x1').is_leader, 1, 'seeded from the roster rather than left blank')
|
||||
|
||||
registries._reset()
|
||||
provide({
|
||||
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
|
||||
getTeamMembers: async () => ({ ok: true, members: [member('0x1', { leader: false })] }),
|
||||
getTeamLeaders: async () => ({ ok: true, leaders: ['0x1'] }),
|
||||
})
|
||||
await teamSync.reconcileNow('roster disagrees with the authority')
|
||||
assert.equal(membersOf(1).get('0x1').is_leader, 1, 'getTeamLeaders() is path 2, and the roster is not')
|
||||
})
|
||||
|
||||
// ── Gate 4: an authoritative empty roster ──────────────────────────────────
|
||||
|
||||
test('gate 4 — the first empty roster departs nobody', async () => {
|
||||
let members = [member('0x1'), member('0x2')]
|
||||
provide({
|
||||
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
|
||||
getTeamMembers: async () => ({ ok: true, members }),
|
||||
})
|
||||
await teamSync.reconcileNow('setup')
|
||||
assert.equal(activeMembers(1).length, 2)
|
||||
|
||||
members = []
|
||||
await teamSync.reconcileNow('empty roster')
|
||||
assert.equal(activeMembers(1).length, 2, 'a cold cache must not empty a roster')
|
||||
assert.ok(store.teams[0].members_empty_since)
|
||||
})
|
||||
|
||||
test('gate 4 — a second empty roster is applied', async () => {
|
||||
let members = [member('0x1'), member('0x2')]
|
||||
provide({
|
||||
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
|
||||
getTeamMembers: async () => ({ ok: true, members }),
|
||||
})
|
||||
await teamSync.reconcileNow('setup')
|
||||
|
||||
members = []
|
||||
await teamSync.reconcileNow('first empty')
|
||||
await teamSync.reconcileNow('second empty')
|
||||
assert.equal(activeMembers(1).length, 0, 'the guild really was emptied')
|
||||
assert.equal(store.teams[0].member_count, 0)
|
||||
})
|
||||
|
||||
test('gate 4 — a non-empty roster clears the quarantine', async () => {
|
||||
let members = [member('0x1')]
|
||||
provide({
|
||||
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
|
||||
getTeamMembers: async () => ({ ok: true, members }),
|
||||
})
|
||||
await teamSync.reconcileNow('setup')
|
||||
|
||||
members = []
|
||||
await teamSync.reconcileNow('empty')
|
||||
assert.ok(store.teams[0].members_empty_since)
|
||||
|
||||
members = [member('0x1')]
|
||||
await teamSync.reconcileNow('recovered')
|
||||
assert.equal(store.teams[0].members_empty_since, null)
|
||||
})
|
||||
|
||||
test('gate 4 — a team that never had members takes an empty roster at once', async () => {
|
||||
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) })
|
||||
const result = await teamSync.reconcileNow('test')
|
||||
assert.equal(result.rosters, 1, 'nothing is at risk, so nothing is quarantined')
|
||||
assert.equal(store.teams[0].members_empty_since, null)
|
||||
})
|
||||
|
||||
// ── Ordinary syncing ───────────────────────────────────────────────────────
|
||||
|
||||
test('a new team is created with a slug, and its roster and counts follow', async () => {
|
||||
provide({
|
||||
getTeams: async () => ({ ok: true, teams: [team('g1', 'The Silver Hand', { abbr: 'TSH' })] }),
|
||||
getTeamMembers: async () => ({
|
||||
ok: true,
|
||||
members: [member('0x1', { userId: 7, online: true }), member('0x2')],
|
||||
}),
|
||||
getTeamLeaders: async () => ({ ok: true, leaders: ['0x1'] }),
|
||||
})
|
||||
const result = await teamSync.reconcileNow('test')
|
||||
|
||||
assert.equal(result.created, 1)
|
||||
const row = store.teams[0]
|
||||
assert.equal(row.slug, 'the-silver-hand')
|
||||
assert.equal(row.member_count, 2)
|
||||
assert.equal(row.linked_count, 1)
|
||||
assert.equal(row.online_count, 1)
|
||||
assert.equal(membersOf(1).get('0x1').is_leader, 1)
|
||||
})
|
||||
|
||||
test('a member who disappears from a complete roster is departed, not deleted', async () => {
|
||||
let members = [member('0x1'), member('0x2')]
|
||||
provide({
|
||||
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
|
||||
getTeamMembers: async () => ({ ok: true, members }),
|
||||
})
|
||||
await teamSync.reconcileNow('setup')
|
||||
|
||||
members = [member('0x1')]
|
||||
await teamSync.reconcileNow('test')
|
||||
assert.equal(membersOf(1).get('0x2').status, 'departed', 'the row survives so history and rejoins do')
|
||||
assert.equal(activeMembers(1).length, 1)
|
||||
})
|
||||
|
||||
test('a rejoining member revives their row and keeps their first_seen_at', async () => {
|
||||
let members = [member('0x1'), member('0x2')]
|
||||
provide({
|
||||
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
|
||||
getTeamMembers: async () => ({ ok: true, members }),
|
||||
})
|
||||
await teamSync.reconcileNow('setup')
|
||||
members = [member('0x1')]
|
||||
await teamSync.reconcileNow('leaves')
|
||||
members = [member('0x1'), member('0x2')]
|
||||
await teamSync.reconcileNow('returns')
|
||||
|
||||
assert.equal(membersOf(1).get('0x2').status, 'active')
|
||||
assert.equal(membersOf(1).get('0x2').first_seen_at, 'first', 'a rejoin is a revived row, not a second one')
|
||||
})
|
||||
|
||||
test('an incomplete roster adds and updates but removes nothing', async () => {
|
||||
provide({
|
||||
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
|
||||
getTeamMembers: async () => ({ ok: true, members: [member('0x1'), member('0x2')] }),
|
||||
})
|
||||
await teamSync.reconcileNow('setup')
|
||||
|
||||
registries._reset()
|
||||
provide({
|
||||
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
|
||||
getTeamMembers: async () => ({ ok: true, complete: false, members: [member('0x3')] }),
|
||||
})
|
||||
await teamSync.reconcileNow('partial')
|
||||
assert.equal(activeMembers(1).length, 3, 'a partial answer is not a claim about who is absent')
|
||||
})
|
||||
|
||||
test('a team absent from a complete list is archived as disbanded', async () => {
|
||||
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A'), team('g2', 'B')] }) })
|
||||
await teamSync.reconcileNow('setup')
|
||||
|
||||
registries._reset()
|
||||
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) })
|
||||
await teamSync.reconcileNow('test')
|
||||
|
||||
assert.equal(activeTeams().length, 1)
|
||||
const archived = store.teams.find((t) => t.external_id === 'g2')
|
||||
assert.equal(archived.status, 'archived')
|
||||
assert.equal(archived.archived_reason, 'disbanded')
|
||||
})
|
||||
|
||||
// ── The rename rule (§2.2) ─────────────────────────────────────────────────
|
||||
|
||||
test('a renamed team is archived and succeeded, never edited in place', async () => {
|
||||
provide({
|
||||
getTeams: async () => ({ ok: true, teams: [team('g1', 'The Silver Hand')] }),
|
||||
getTeamMembers: async () => ({ ok: true, members: [member('0x1')] }),
|
||||
})
|
||||
await teamSync.reconcileNow('setup')
|
||||
|
||||
registries._reset()
|
||||
provide({
|
||||
getTeams: async () => ({ ok: true, teams: [team('g1', 'The Golden Hand')] }),
|
||||
getTeamMembers: async () => ({ ok: true, members: [member('0x1')] }),
|
||||
})
|
||||
const result = await teamSync.reconcileNow('rename')
|
||||
|
||||
assert.equal(result.renamed, 1)
|
||||
const [old_, next] = store.teams
|
||||
assert.equal(old_.name, 'The Silver Hand', 'the name is immutable for the life of the row')
|
||||
assert.equal(old_.status, 'archived')
|
||||
assert.equal(old_.archived_reason, 'renamed')
|
||||
assert.equal(old_.succeeded_by, next.id, 'the old slug can explain itself instead of 404ing')
|
||||
assert.equal(next.name, 'The Golden Hand')
|
||||
assert.equal(next.slug, 'the-golden-hand')
|
||||
assert.equal(next.status, 'active')
|
||||
})
|
||||
|
||||
test('a rename back to a previous name does not reuse the retired slug', async () => {
|
||||
const names = ['Alpha', 'Beta', 'Alpha']
|
||||
let i = 0
|
||||
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', names[i])] }) })
|
||||
await teamSync.reconcileNow('a')
|
||||
i = 1
|
||||
await teamSync.reconcileNow('b')
|
||||
i = 2
|
||||
await teamSync.reconcileNow('c')
|
||||
|
||||
const slugs = store.teams.map((t) => t.slug)
|
||||
assert.deepEqual(slugs, ['alpha', 'beta', 'alpha-2'])
|
||||
assert.equal(new Set(slugs).size, 3, 'an archived team stays readable at its own address')
|
||||
})
|
||||
|
||||
test('two teams with the same name get distinct slugs', async () => {
|
||||
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'Guard'), team('g2', 'Guard')] }) })
|
||||
await teamSync.reconcileNow('test')
|
||||
assert.deepEqual(store.teams.map((t) => t.slug), ['guard', 'guard-2'])
|
||||
})
|
||||
|
||||
test('a name with nothing URL-safe in it still gets an address', async () => {
|
||||
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', '★☆★')] }) })
|
||||
await teamSync.reconcileNow('test')
|
||||
assert.equal(store.teams[0].slug, 'team')
|
||||
assert.equal(store.teams[0].name, '★☆★', 'the identity keeps what the player typed')
|
||||
})
|
||||
|
||||
// ── Screening is on the create path, and on every run ──────────────────────
|
||||
|
||||
test('every newly created team has its name screened', async () => {
|
||||
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'Admin'), team('g2', 'The Silver Hand')] }) })
|
||||
await teamSync.reconcileNow('test')
|
||||
assert.deepEqual(store.screened, ['Admin', 'The Silver Hand'])
|
||||
})
|
||||
|
||||
test('a renamed team is screened again under its new name', async () => {
|
||||
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'Ordinary')] }) })
|
||||
await teamSync.reconcileNow('setup')
|
||||
|
||||
registries._reset()
|
||||
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'Admin')] }) })
|
||||
await teamSync.reconcileNow('rename')
|
||||
assert.deepEqual(store.screened, ['Ordinary', 'Admin'], 'a rename is a create, so it screens')
|
||||
})
|
||||
|
||||
test('a hidden team is still created and still syncs its roster', async () => {
|
||||
// Hide, never reject: the Team works completely for its own members. The people
|
||||
// in it are not being punished for a name their leader chose.
|
||||
patch(moderation, 'screenForCreate', async () => ({
|
||||
hidden: true, hiddenReason: 'reserved_name', hiddenTerm: 'admin',
|
||||
}))
|
||||
provide({
|
||||
getTeams: async () => ({ ok: true, teams: [team('g1', 'Admin')] }),
|
||||
getTeamMembers: async () => ({ ok: true, members: [member('0x1'), member('0x2')] }),
|
||||
})
|
||||
await teamSync.reconcileNow('test')
|
||||
|
||||
assert.equal(store.teams[0].hidden, 1)
|
||||
assert.equal(store.teams[0].hidden_term, 'admin')
|
||||
assert.equal(activeMembers(1).length, 2, 'suppression is a public-surface rule, not a shutdown')
|
||||
assert.equal(store.teams[0].member_count, 2)
|
||||
})
|
||||
|
||||
test('a successful run re-screens the names no human has ruled on', async () => {
|
||||
let called = 0
|
||||
patch(moderation, 'rescreen', async () => { called += 1; return 0 })
|
||||
provide()
|
||||
await teamSync.reconcileNow('test')
|
||||
assert.equal(called, 1)
|
||||
})
|
||||
|
||||
test('a refused run does not re-screen — it does nothing at all', async () => {
|
||||
let called = 0
|
||||
patch(moderation, 'rescreen', async () => { called += 1; return 0 })
|
||||
provide({ getTeams: async () => ({ ok: false, reason: 'down' }) })
|
||||
await teamSync.reconcileNow('test')
|
||||
assert.equal(called, 0)
|
||||
})
|
||||
|
||||
// ── Events (§2.3) ──────────────────────────────────────────────────────────
|
||||
|
||||
test('an unknown event kind is rejected', async () => {
|
||||
provide()
|
||||
await assert.rejects(() => teamSync.publish({ kind: 'team.exploded', externalId: 'g1' }), /unknown event kind/)
|
||||
})
|
||||
|
||||
test('an event with no externalId is rejected', async () => {
|
||||
provide()
|
||||
await assert.rejects(() => teamSync.publish({ kind: 'team.member.added' }), /no externalId/)
|
||||
})
|
||||
|
||||
test('team.disbanded never archives — it asks for a reconciliation', async () => {
|
||||
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) })
|
||||
await teamSync.reconcileNow('setup')
|
||||
|
||||
await teamSync.publish({ kind: 'team.disbanded', externalId: 'g1' })
|
||||
assert.equal(activeTeams().length, 1, 'destruction is never driven by a delta that may be a repeat')
|
||||
})
|
||||
|
||||
test('team.created does not invent a team', async () => {
|
||||
provide()
|
||||
await teamSync.publish({ kind: 'team.created', externalId: 'brand-new' })
|
||||
assert.equal(store.teams.length, 0, 'a team built from a delta has no name, roster or leaders')
|
||||
})
|
||||
|
||||
test('a member delta applies at once for a known team and updates the counts', async () => {
|
||||
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) })
|
||||
await teamSync.reconcileNow('setup')
|
||||
|
||||
await teamSync.publish({
|
||||
kind: 'team.member.added', externalId: 'g1', memberKey: '0x5', displayName: 'Ada', userId: 3,
|
||||
})
|
||||
assert.equal(activeMembers(1).length, 1)
|
||||
assert.equal(membersOf(1).get('0x5').display_name, 'Ada')
|
||||
assert.equal(store.teams[0].member_count, 1)
|
||||
assert.equal(store.teams[0].linked_count, 1)
|
||||
|
||||
await teamSync.publish({ kind: 'team.member.removed', externalId: 'g1', memberKey: '0x5' })
|
||||
assert.equal(activeMembers(1).length, 0)
|
||||
assert.equal(store.teams[0].member_count, 0)
|
||||
})
|
||||
|
||||
test('a leadership delta writes is_leader and nothing else', async () => {
|
||||
provide({
|
||||
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
|
||||
getTeamMembers: async () => ({ ok: true, members: [member('0x1')] }),
|
||||
})
|
||||
await teamSync.reconcileNow('setup')
|
||||
|
||||
await teamSync.publish({ kind: 'team.leader.added', externalId: 'g1', memberKey: '0x1' })
|
||||
assert.equal(membersOf(1).get('0x1').is_leader, 1)
|
||||
assert.equal(activeMembers(1).length, 1, 'promotion is not a join')
|
||||
|
||||
await teamSync.publish({ kind: 'team.leader.removed', externalId: 'g1', memberKey: '0x1' })
|
||||
assert.equal(membersOf(1).get('0x1').is_leader, 0)
|
||||
assert.equal(activeMembers(1).length, 1, 'demotion is not a departure')
|
||||
})
|
||||
|
||||
test('a leadership delta for an unknown member creates nobody', async () => {
|
||||
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) })
|
||||
await teamSync.reconcileNow('setup')
|
||||
await teamSync.publish({ kind: 'team.leader.added', externalId: 'g1', memberKey: '0xdead' })
|
||||
assert.equal(activeMembers(1).length, 0, 'a promotion is not evidence of membership')
|
||||
})
|
||||
|
||||
test('an event for an unknown team asks for a reconciliation instead of guessing', async () => {
|
||||
provide()
|
||||
await teamSync.publish({ kind: 'team.member.added', externalId: 'nope', memberKey: '0x1' })
|
||||
assert.equal(store.teams.length, 0)
|
||||
})
|
||||
|
||||
test('publish is a no-op when no provider is registered', async () => {
|
||||
await teamSync.publish({ kind: 'team.member.added', externalId: 'g1', memberKey: '0x1' })
|
||||
assert.equal(store.teams.length, 0)
|
||||
})
|
||||
|
||||
// ── Scheduling ─────────────────────────────────────────────────────────────
|
||||
|
||||
test('a run already in flight is joined rather than run twice', async () => {
|
||||
let calls = 0
|
||||
let release
|
||||
const gate = new Promise((resolve) => { release = resolve })
|
||||
provide({
|
||||
getTeams: async () => {
|
||||
calls += 1
|
||||
await gate
|
||||
return { ok: true, teams: [] }
|
||||
},
|
||||
})
|
||||
|
||||
const first = teamSync.reconcileNow('first')
|
||||
const second = await teamSync.reconcileNow('second')
|
||||
assert.equal(second.joined, true)
|
||||
release()
|
||||
await first
|
||||
assert.equal(calls, 1, 'the lock is what stops two runs writing the same rows')
|
||||
})
|
||||
|
||||
test('the poll interval falls back and is floored against a bad setting', async () => {
|
||||
patch(settings, 'get', async () => 'not a number')
|
||||
assert.equal(await teamSync.intervalSeconds(), teamSync.DEFAULT_INTERVAL_S)
|
||||
|
||||
patch(settings, 'get', async () => '5')
|
||||
assert.equal(await teamSync.intervalSeconds(), teamSync.DEFAULT_INTERVAL_S, 'a hot loop is not a valid interval')
|
||||
|
||||
patch(settings, 'get', async () => '120')
|
||||
assert.equal(await teamSync.intervalSeconds(), 120)
|
||||
|
||||
patch(settings, 'get', async () => { throw new Error('db down') })
|
||||
assert.equal(await teamSync.intervalSeconds(), teamSync.DEFAULT_INTERVAL_S)
|
||||
})
|
||||
|
||||
test('backoff grows with failures and is capped at the poll interval', async () => {
|
||||
assert.equal(teamSync.backoffSeconds(0, 900), 900, 'no failures means the ordinary poll')
|
||||
assert.equal(teamSync.backoffSeconds(1, 900), 30)
|
||||
assert.equal(teamSync.backoffSeconds(2, 900), 60)
|
||||
assert.ok(teamSync.backoffSeconds(4, 900) < 900)
|
||||
assert.equal(teamSync.backoffSeconds(50, 900), 900, 'a module down for a day must recover promptly, not in weeks')
|
||||
})
|
||||
|
||||
test('start() is inert with no provider registered', async () => {
|
||||
await teamSync.start()
|
||||
assert.equal(store.teams.length, 0)
|
||||
})
|
||||
Reference in New Issue
Block a user