feat(teams): Team core — the reconciler, the four authority paths, and the impersonation controls #151

Merged
whitlocktech merged 6 commits from feat/teams-phase2-team-core into edge 2026-08-17 22:20:34 +00:00
42 changed files with 9718 additions and 5 deletions

View File

@@ -42,6 +42,7 @@ import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
import UserDetail from './routes/admin/views/UserDetail.jsx'
import InvitesAdmin from './routes/admin/views/InvitesAdmin.jsx'
import ModulesAdmin from './routes/admin/views/ModulesAdmin.jsx'
import TeamsAdmin from './routes/admin/views/TeamsAdmin.jsx'
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
import Moderation from './routes/admin/views/Moderation.jsx'
import ModerationUser from './routes/admin/views/ModerationUser.jsx'
@@ -174,6 +175,10 @@ export default function App() {
the volume in the first place. Declared here with the rest of
core's routes, above the module-supplied ones below. */}
<Route path="modules" element={<ModulesAdmin />} />
{/* Staff-wide, like the moderation queues: the gate on the three
actions that publish a game-written name is applied per request
on the server, from the caller's live role (TEAMS.md 2.9). */}
<Route path="teams" element={<TeamsAdmin />} />
<Route path="account" element={<AccountAdmin />} />
{/* Installed modules' admin pages, at /admin/<id>/…, already inside
RequireAuth + AdminLayout. A module cannot supply its own auth

View File

@@ -247,6 +247,29 @@ export const api = {
setModuleSources: (hosts) => req('/admin/modules/sources', { method: 'PUT', body: { hosts } }),
restartServer: () => req('/admin/modules/restart', { method: 'POST' }),
// Teams (docs/website/TEAMS.md §2.11). Three of these mean something
// different depending on who calls them: for a moderator, unhide and
// setTeamDisplayName file a request and the response says `pending: true`.
// The caller does not choose — the server decides from the live role — so
// there is deliberately no "asRequest" argument to get wrong.
listTeams: () => req('/admin/teams'),
getTeam: (id) => req(`/admin/teams/${id}`),
resyncTeams: () => req('/admin/teams/resync', { method: 'POST' }),
archiveTeam: (id, reason) => req(`/admin/teams/${id}/archive`, { method: 'POST', body: { reason } }),
teamGrants: (id) => req(`/admin/teams/${id}/grants`),
hideTeam: (id, reason) => req(`/admin/teams/${id}/hide`, { method: 'POST', body: { reason } }),
unhideTeam: (id, reason) => req(`/admin/teams/${id}/unhide`, { method: 'POST', body: { reason } }),
setTeamDisplayName: (id, displayName, reason) =>
req(`/admin/teams/${id}/display-name`, { method: 'POST', body: { displayName, reason } }),
setTeamLeaderOverride: (id, body) =>
req(`/admin/teams/${id}/leader-override`, { method: 'POST', body }),
clearTeamLeaderOverride: (id, memberKey) =>
req(`/admin/teams/${id}/leader-override/${encodeURIComponent(memberKey)}`, { method: 'DELETE' }),
teamReviewQueue: () => req('/admin/teams/review'),
teamRequests: (status) => req(`/admin/teams/requests${status ? `?status=${status}` : ''}`),
decideTeamRequest: (id, status, note) =>
req(`/admin/teams/requests/${id}/decide`, { method: 'POST', body: { status, note } }),
// ----- moderation dashboard (admin + moderator) -----
modSummary: () => req('/admin/moderation/stats/summary'),
modRecent: (params = {}) => {

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

@@ -0,0 +1,140 @@
// What Admin → Teams SAYS, separated from how it renders (docs/website/TEAMS.md
// §2.4, §2.8, §2.9).
//
// Plain JS with tests, following lib/moduleAdmin.js. The reason it is worth
// splitting here specifically: this screen's job is to tell an operator the
// difference between "the shard has no Teams" and "core has not been able to ask
// for two hours", and those two produce almost the same page. Getting that
// wording right is logic, not markup.
/** Tones the screen uses. Names, not colours — the view maps them. */
export const TONE = { ok: 'ok', warn: 'warn', bad: 'bad', idle: 'idle' }
/**
* How to describe the projection's freshness.
*
* The four states are genuinely different and an operator needs to tell them
* apart:
*
* - no provider registered — nothing to sync, and not a fault;
* - never synced — core has an empty projection it has never confirmed, which
* must NOT read as "there are no Teams";
* - stale — the projection is real but old, and the reason is usually in
* `lastError`;
* - current.
*/
export function freshnessOf(sync = {}) {
if (!sync.configured) {
return { tone: TONE.idle, label: 'No Team provider', detail: 'No installed module supplies Teams.' }
}
if (!sync.lastSyncAt) {
return {
tone: TONE.bad,
label: 'Never synced',
detail: 'Core has never had an answer it could trust. What is shown below is not a confirmed empty shard.',
}
}
if (sync.stale) {
return {
tone: TONE.warn,
label: 'Stale',
detail: `Last confirmed ${ago(sync.lastSyncAt)}. Rosters below may be out of date.`,
}
}
return { tone: TONE.ok, label: 'Current', detail: `Last confirmed ${ago(sync.lastSyncAt)}.` }
}
/**
* A short, human age. Deliberately coarse: this exists so a sentence reads
* "confirmed 14 minutes ago", and second-level precision would be false comfort
* about a projection whose interval is fifteen minutes.
*/
export function ago(value) {
if (!value) return 'never'
const seconds = Math.max(0, Math.round((Date.now() - new Date(value).getTime()) / 1000))
if (seconds < 90) return 'just now'
const minutes = Math.round(seconds / 60)
if (minutes < 60) return `${minutes} minutes ago`
const hours = Math.round(minutes / 60)
if (hours < 48) return `${hours} hour${hours === 1 ? '' : 's'} ago`
return `${Math.round(hours / 24)} days ago`
}
/** The status pill for one Team row. */
export function statusOf(team = {}) {
if (team.status === 'archived') {
return { tone: TONE.idle, label: team.archivedReason === 'renamed' ? 'Renamed' : 'Archived' }
}
if (team.hidden && team.hiddenReason === 'reserved_name') {
return { tone: TONE.bad, label: 'Hidden — reserved name' }
}
if (team.hidden) return { tone: TONE.warn, label: 'Hidden by staff' }
return { tone: TONE.ok, label: 'Public' }
}
/**
* What a staff member is told will happen when they press the button.
*
* The gate is decided server-side from the caller's live role, so this only
* describes it. Saying "Request" to a moderator and "Apply" to an admin is what
* stops the pending result being a surprise.
*/
export function gateLabelFor(role, verb) {
return role === 'admin' ? verb : `Request ${verb.toLowerCase()}`
}
/** The three gated actions, for the note under the buttons. */
export const GATED_NOTE =
'Publishing a game-written name needs an admin: a moderators un-hide or display-name change '
+ 'is filed for approval. Hiding is not gated — suppression is always safe.'
/** A one-line description of a queued request, for the approval queue. */
export function describeRequest(request = {}) {
const payload = parsePayload(request.payload)
const who = request.requested_username || 'a deleted user'
switch (request.action) {
case 'unhide':
return `${who} asks to publish “${request.team_name}`
case 'display_name_override':
return `${who} asks to display “${request.team_name}” as “${payload.displayName || ''}`
case 'clear_display_name_override':
return `${who} asks to clear the display name on “${request.team_name}`
default:
return `${who} asks for “${request.action}” on “${request.team_name}`
}
}
/**
* The payload may arrive parsed or as a JSON string depending on the driver, so
* this normalises rather than assuming either. The server has the same note.
*/
export function parsePayload(payload) {
if (payload == null) return {}
if (typeof payload === 'object') return payload
try {
return JSON.parse(payload)
} catch {
return {}
}
}
/**
* How a member's leadership should read.
*
* An override is shown AS an override rather than folded into the answer: staff
* looking at a roster need to see that a decision was made, not a fact that looks
* like the game's.
*/
export function leadershipOf(member = {}) {
if (!member.leaderOverride) {
return { isLeader: Boolean(member.isLeader), overridden: false, note: null }
}
const granted = member.leaderOverride.effect === 'grant'
return {
isLeader: granted,
overridden: true,
note: `${granted ? 'Granted' : 'Denied'} by ${member.leaderOverride.by || 'a deleted user'}`
+ `${member.leaderOverride.reason ? `${member.leaderOverride.reason}` : ''}`
+ ` (the game says ${member.isLeaderSynced ? 'leader' : 'not a leader'})`,
}
}

View File

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

View File

@@ -76,6 +76,12 @@ export const NAV = [
items: [
{ to: '/admin/moderation', label: 'Moderation', icon: IconShield, roles: ['admin', 'moderator'] },
{ to: '/admin/moderation/appeals', label: 'Appeals', icon: IconShield, roles: ['admin', 'moderator'] },
// Moderation rather than System: the screen's daily job is the
// reserved-name review queue, which is moderator work. The three actions
// that publish a game-written name are gated to admins server-side, so a
// moderator reaching this screen is correct — what they do here is file a
// request (TEAMS.md §2.9).
{ to: '/admin/teams', label: 'Teams', icon: IconUsers, roles: ['admin', 'moderator'] },
],
},
{

View File

@@ -0,0 +1,301 @@
import { useCallback, useEffect, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { dateTime } from '../../../lib/format.js'
import {
freshnessOf, statusOf, gateLabelFor, describeRequest, leadershipOf, GATED_NOTE,
} from '../../../lib/teamAdmin.js'
import { useAuth } from '../../../contexts/AuthContext.jsx'
import { api } from '../../../api/client.js'
// Admin → Teams (docs/website/TEAMS.md §2.4, §2.8, §2.9).
//
// Three panels, in the order an operator needs them:
//
// 1. **Sync state**, verbatim, including the last error. The screen's first job
// is to make "the shard has no Teams" and "core has not been able to ask for
// two hours" impossible to confuse — they render almost identically
// otherwise, and one is fine while the other is an outage.
// 2. **The review queue** — Teams auto-hidden because their name matched the
// impersonation list, each showing which term matched.
// 3. **The approval queue** — what moderators have asked to publish.
//
// Everything that decides what a row SAYS lives in lib/teamAdmin.js, which is
// plain JS and has tests; this file renders it.
const TONE_COLOR = { ok: '#7fd0a4', warn: 'var(--accent)', bad: '#d98b84', idle: 'var(--muted)' }
function Pill({ tone, children }) {
return (
<span
className="badge"
style={{ color: TONE_COLOR[tone] || 'var(--muted)', borderColor: 'var(--line)', background: 'var(--panel-flat)' }}
>
{children}
</span>
)
}
// ── Sync state ─────────────────────────────────────────────────────────────
function SyncPanel({ sync, syncState, onResync, busy }) {
const freshness = freshnessOf(sync)
return (
<section className="panel" style={{ marginBottom: '1.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '.75rem', flexWrap: 'wrap' }}>
<h2 style={{ margin: 0 }}>Sync</h2>
<Pill tone={freshness.tone}>{freshness.label}</Pill>
<button type="button" className="btn" onClick={onResync} disabled={busy || !sync.configured}>
{busy ? 'Resyncing…' : 'Resync now'}
</button>
</div>
<p className="muted" style={{ marginTop: '.5rem' }}>{freshness.detail}</p>
{syncState && (
<dl className="kv" style={{ marginTop: '.75rem' }}>
<dt>Module</dt><dd>{syncState.moduleId}</dd>
<dt>Last attempt</dt><dd>{dateTime(syncState.lastAttemptAt) || 'never'}</dd>
<dt>Last success</dt><dd>{dateTime(syncState.lastSuccessAt) || 'never'}</dd>
<dt>Consecutive failures</dt><dd>{syncState.consecutiveFailures}</dd>
{syncState.lastError && (
<>
{/* Verbatim. An operator debugging a stale projection needs what the
provider actually said, not a friendlier paraphrase of it. */}
<dt>Last error</dt>
<dd style={{ color: TONE_COLOR.bad }}>{syncState.lastError}</dd>
</>
)}
{syncState.pendingEmptySince && (
<>
<dt>Empty answer held</dt>
<dd>
since {dateTime(syncState.pendingEmptySince)} an authoritative but empty list is
applied only if the next answer agrees.
</dd>
</>
)}
</dl>
)}
</section>
)
}
// ── The reserved-name review queue ─────────────────────────────────────────
function ReviewQueue({ rows, role, onAct, busy }) {
if (!rows.length) return null
return (
<section className="panel" style={{ marginBottom: '1.5rem' }}>
<h2>Names to review</h2>
<p className="muted">
These Teams are hidden from every public surface because their name matched a reserved term.
They work normally for their own members. {GATED_NOTE}
</p>
<table className="table">
<thead>
<tr><th>Name</th><th>Matched</th><th>Members</th><th>Created</th><th /></tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id}>
<td>{row.name}</td>
<td><Pill tone="bad">{row.hidden_term}</Pill></td>
<td>{row.member_count}</td>
<td>{dateTime(row.created_at)}</td>
<td>
<button type="button" className="btn" disabled={busy} onClick={() => onAct(row.id, 'unhide')}>
{gateLabelFor(role, 'Publish')}
</button>
</td>
</tr>
))}
</tbody>
</table>
</section>
)
}
// ── The approval queue ─────────────────────────────────────────────────────
function RequestQueue({ rows, role, onDecide, busy }) {
if (!rows.length) return null
const canDecide = role === 'admin'
return (
<section className="panel" style={{ marginBottom: '1.5rem' }}>
<h2>Awaiting approval</h2>
<p className="muted">
{canDecide
? 'Approving publishes the name; rejecting keeps the record and changes nothing.'
: 'Only an admin can decide these. Your own requests stay here until one does.'}
</p>
<ul className="list">
{rows.map((row) => (
<li key={row.id} style={{ display: 'flex', gap: '.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
<span>{describeRequest(row)}</span>
<span className="muted">{dateTime(row.requested_at)}</span>
{row.reason && <span className="muted">{row.reason}</span>}
{canDecide && (
<>
<button type="button" className="btn" disabled={busy} onClick={() => onDecide(row.id, 'approved')}>
Approve
</button>
<button type="button" className="btn" disabled={busy} onClick={() => onDecide(row.id, 'rejected')}>
Reject
</button>
</>
)}
</li>
))}
</ul>
</section>
)
}
// ── One Team ───────────────────────────────────────────────────────────────
function TeamRow({ team, role, onAct, busy }) {
const status = statusOf(team)
return (
<tr>
<td>
{team.displayName}
{team.displayNameOverride && (
<div className="muted" style={{ fontSize: '.85em' }}>
shown instead of {team.name}
</div>
)}
</td>
<td><Pill tone={status.tone}>{status.label}</Pill></td>
<td>{team.memberCount}</td>
<td>{team.linkedCount}</td>
<td>{team.onlineCount}</td>
<td className="muted">{dateTime(team.rosterSyncedAt) || 'never'}</td>
<td>
{team.status === 'active' && (team.hidden
? (
<button type="button" className="btn" disabled={busy} onClick={() => onAct(team.id, 'unhide')}>
{gateLabelFor(role, 'Publish')}
</button>
)
: (
<button type="button" className="btn" disabled={busy} onClick={() => onAct(team.id, 'hide')}>
Hide
</button>
))}
</td>
</tr>
)
}
// ── The screen ─────────────────────────────────────────────────────────────
export default function TeamsAdmin() {
const { user } = useAuth()
const role = user ? user.role : null
const [data, setData] = useState(null)
const [review, setReview] = useState([])
const [requests, setRequests] = useState([])
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const load = useCallback(async () => {
setError('')
try {
const [teams, reviewQueue, requestQueue] = await Promise.all([
api.admin.listTeams(),
api.admin.teamReviewQueue(),
api.admin.teamRequests('pending'),
])
setData(teams)
setReview(reviewQueue.teams || [])
setRequests(requestQueue.requests || [])
} catch (err) {
setError(err.message || 'Could not load Teams.')
}
}, [])
useEffect(() => { load() }, [load])
async function run(fn, pendingMessage) {
setBusy(true)
setNotice('')
setError('')
try {
const result = await fn()
// The server decides whether an action applied or was filed, from the
// caller's live role. Saying so plainly is what stops a moderator thinking
// nothing happened.
if (result && result.pending) setNotice(pendingMessage)
await load()
} catch (err) {
setError(err.message || 'That did not work.')
} finally {
setBusy(false)
}
}
const act = (id, action) => run(
() => (action === 'hide' ? api.admin.hideTeam(id) : api.admin.unhideTeam(id)),
'Filed for approval. Nothing has changed publicly until an admin approves it.',
)
const decide = (id, status) => run(
() => api.admin.decideTeamRequest(id, status),
'',
)
const resync = () => run(async () => {
const result = await api.admin.resyncTeams()
// A refusal is the normal, designed outcome when the provider cannot answer,
// so it is reported as a result rather than thrown as an error.
if (!result.ok) setError(`Resync refused: ${result.reason}. Nothing was changed.`)
else if (result.quarantined) {
setNotice('The provider answered with an empty list. It is being held for confirmation, not applied.')
}
return null
}, '')
if (error && !data) return <ErrorState message={error} />
if (!data) return <Loading />
return (
<div>
<h1>Teams</h1>
{error && <ErrorState message={error} />}
{notice && <p className="notice">{notice}</p>}
<SyncPanel sync={data} syncState={data.syncState} onResync={resync} busy={busy} />
<ReviewQueue rows={review} role={role} onAct={act} busy={busy} />
<RequestQueue rows={requests} role={role} onDecide={decide} busy={busy} />
<section className="panel">
<h2>All Teams</h2>
{!data.teams.length && (
<p className="muted">
{data.configured
? 'No Teams in the projection yet.'
: 'No installed module supplies Teams, so there is nothing to show.'}
</p>
)}
{data.teams.length > 0 && (
<table className="table">
<thead>
<tr>
<th>Name</th><th>Status</th><th>Members</th><th>Linked</th><th>Online</th>
<th>Roster confirmed</th><th />
</tr>
</thead>
<tbody>
{data.teams.map((team) => (
<TeamRow key={team.id} team={team} role={role} onAct={act} busy={busy} />
))}
</tbody>
</table>
)}
</section>
</div>
)
}
export { leadershipOf }

View File

@@ -0,0 +1,140 @@
// What Admin → Teams says (client/src/lib/teamAdmin.js).
//
// The test that earns this file: "no Teams" and "core has not been able to ask"
// must never read the same. They produce almost identical screens — an empty
// table — and one is fine while the other is an outage an operator needs to act
// on. Everything else here is in service of that distinction.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
freshnessOf, ago, statusOf, gateLabelFor, describeRequest, parsePayload, leadershipOf, TONE,
} from '../src/lib/teamAdmin.js'
const minutesAgo = (n) => new Date(Date.now() - n * 60_000).toISOString()
// ── Freshness: four states that must not be confused ───────────────────────
test('no provider is idle, not a fault', () => {
const f = freshnessOf({ configured: false })
assert.equal(f.tone, TONE.idle)
assert.match(f.label, /No Team provider/)
})
test('never synced is reported as never synced, not as an empty shard', () => {
// The failure this prevents: an empty projection core has never confirmed,
// rendered as though the game genuinely has no Teams.
const f = freshnessOf({ configured: true, lastSyncAt: null })
assert.equal(f.tone, TONE.bad)
assert.equal(f.label, 'Never synced')
assert.match(f.detail, /not a confirmed empty shard/)
})
test('stale says how old it is', () => {
const f = freshnessOf({ configured: true, stale: true, lastSyncAt: minutesAgo(14) })
assert.equal(f.tone, TONE.warn)
assert.equal(f.label, 'Stale')
assert.match(f.detail, /14 minutes ago/)
})
test('current says so plainly', () => {
const f = freshnessOf({ configured: true, stale: false, lastSyncAt: minutesAgo(2) })
assert.equal(f.tone, TONE.ok)
assert.equal(f.label, 'Current')
})
test('ago is deliberately coarse', () => {
// Second-level precision would be false comfort about a projection whose poll
// interval is fifteen minutes.
assert.equal(ago(null), 'never')
assert.equal(ago(new Date().toISOString()), 'just now')
assert.equal(ago(minutesAgo(14)), '14 minutes ago')
assert.equal(ago(minutesAgo(60)), '1 hour ago')
assert.equal(ago(minutesAgo(180)), '3 hours ago')
assert.equal(ago(minutesAgo(60 * 72)), '3 days ago')
})
// ── Status ─────────────────────────────────────────────────────────────────
test('the four Team statuses are distinguishable', () => {
assert.equal(statusOf({ status: 'active' }).label, 'Public')
assert.equal(statusOf({ status: 'active', hidden: 1, hiddenReason: 'reserved_name' }).label, 'Hidden — reserved name')
assert.equal(statusOf({ status: 'active', hidden: 1, hiddenReason: 'staff' }).label, 'Hidden by staff')
assert.equal(statusOf({ status: 'archived', archivedReason: 'disbanded' }).label, 'Archived')
assert.equal(statusOf({ status: 'archived', archivedReason: 'renamed' }).label, 'Renamed')
})
test('a reserved-name hide is the loudest tone', () => {
assert.equal(statusOf({ status: 'active', hidden: 1, hiddenReason: 'reserved_name' }).tone, TONE.bad)
assert.equal(statusOf({ status: 'active', hidden: 1, hiddenReason: 'staff' }).tone, TONE.warn)
})
// ── The gate, described honestly ───────────────────────────────────────────
test('the button says what will actually happen for this role', () => {
// The server decides from the live role; this only describes it. Saying
// "Publish" to a moderator would make the pending result a surprise.
assert.equal(gateLabelFor('admin', 'Publish'), 'Publish')
assert.equal(gateLabelFor('moderator', 'Publish'), 'Request publish')
})
// ── The approval queue ─────────────────────────────────────────────────────
test('a request describes itself, including the name being published', () => {
assert.equal(
describeRequest({ action: 'unhide', requested_username: 'mod1', team_name: 'Admin' }),
'mod1 asks to publish “Admin”',
)
assert.equal(
describeRequest({
action: 'display_name_override', requested_username: 'mod1', team_name: 'Admin',
payload: { displayName: 'The Old Guard' },
}),
'mod1 asks to display “Admin” as “The Old Guard”',
)
assert.equal(
describeRequest({ action: 'clear_display_name_override', requested_username: 'mod1', team_name: 'X' }),
'mod1 asks to clear the display name on “X”',
)
})
test('a deleted requester still reads as a sentence', () => {
// §2.10 sets requested_by to NULL and keeps the username snapshot; when even
// that is gone the queue must not render "null asks to publish".
assert.match(describeRequest({ action: 'unhide', team_name: 'Admin' }), /^a deleted user asks/)
})
test('a payload arrives parsed or as a string, and both work', () => {
assert.deepEqual(parsePayload({ displayName: 'X' }), { displayName: 'X' })
assert.deepEqual(parsePayload('{"displayName":"X"}'), { displayName: 'X' })
assert.deepEqual(parsePayload(null), {})
assert.deepEqual(parsePayload('not json'), {})
})
// ── Leadership shows the decision, not just the answer ─────────────────────
test('an unoverridden member reads straight from the projection', () => {
const l = leadershipOf({ isLeader: true, isLeaderSynced: true })
assert.equal(l.isLeader, true)
assert.equal(l.overridden, false)
assert.equal(l.note, null)
})
test('an override is shown AS an override, with what the game says', () => {
// Staff looking at a roster need to see that a decision was made, not a fact
// that looks like the game's.
const l = leadershipOf({
isLeaderSynced: true,
leaderOverride: { effect: 'deny', by: 'mod1', reason: 'harassment' },
})
assert.equal(l.isLeader, false)
assert.equal(l.overridden, true)
assert.match(l.note, /Denied by mod1 — harassment/)
assert.match(l.note, /the game says leader/)
})
test('a grant override says the game disagrees', () => {
const l = leadershipOf({ isLeaderSynced: false, leaderOverride: { effect: 'grant', by: 'root' } })
assert.equal(l.isLeader, true)
assert.match(l.note, /the game says not a leader/)
})

View File

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

View File

@@ -730,6 +730,145 @@
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/admin/teams",
"handlers": 3,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/admin/teams/:id",
"handlers": 3,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/admin/teams/:id/archive",
"handlers": 4,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/admin/teams/:id/display-name",
"handlers": 5,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/admin/teams/:id/grants",
"handlers": 3,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/admin/teams/:id/hide",
"handlers": 4,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/admin/teams/:id/leader-override",
"handlers": 6,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "DELETE",
"path": "/api/v1/admin/teams/:id/leader-override/:memberKey",
"handlers": 4,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/admin/teams/:id/unhide",
"handlers": 4,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/admin/teams/requests",
"handlers": 3,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/admin/teams/requests/:id/decide",
"handlers": 5,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/admin/teams/resync",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/teams/review",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/admin/uploads",
@@ -1499,6 +1638,24 @@
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/player/teams",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/player/teams/:slug/access",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/public/contact",
@@ -1556,6 +1713,30 @@
"handlers": 1,
"gates": []
},
{
"method": "GET",
"path": "/api/v1/public/teams",
"handlers": 2,
"gates": [
"siteMode"
]
},
{
"method": "GET",
"path": "/api/v1/public/teams/:slug",
"handlers": 2,
"gates": [
"siteMode"
]
},
{
"method": "GET",
"path": "/api/v1/public/teams/:slug/members",
"handlers": 2,
"gates": [
"siteMode"
]
},
{
"method": "GET",
"path": "/api/v1/public/version",

View File

@@ -293,6 +293,58 @@
"method": "PUT",
"path": "/api/v1/admin/site-mode"
},
{
"method": "GET",
"path": "/api/v1/admin/teams"
},
{
"method": "GET",
"path": "/api/v1/admin/teams/:id"
},
{
"method": "POST",
"path": "/api/v1/admin/teams/:id/archive"
},
{
"method": "POST",
"path": "/api/v1/admin/teams/:id/display-name"
},
{
"method": "GET",
"path": "/api/v1/admin/teams/:id/grants"
},
{
"method": "POST",
"path": "/api/v1/admin/teams/:id/hide"
},
{
"method": "POST",
"path": "/api/v1/admin/teams/:id/leader-override"
},
{
"method": "DELETE",
"path": "/api/v1/admin/teams/:id/leader-override/:memberKey"
},
{
"method": "POST",
"path": "/api/v1/admin/teams/:id/unhide"
},
{
"method": "GET",
"path": "/api/v1/admin/teams/requests"
},
{
"method": "POST",
"path": "/api/v1/admin/teams/requests/:id/decide"
},
{
"method": "POST",
"path": "/api/v1/admin/teams/resync"
},
{
"method": "GET",
"path": "/api/v1/admin/teams/review"
},
{
"method": "POST",
"path": "/api/v1/admin/uploads"
@@ -605,6 +657,14 @@
"method": "GET",
"path": "/api/v1/player/appeals/eligible"
},
{
"method": "GET",
"path": "/api/v1/player/teams"
},
{
"method": "GET",
"path": "/api/v1/player/teams/:slug/access"
},
{
"method": "POST",
"path": "/api/v1/public/contact"
@@ -637,6 +697,18 @@
"method": "GET",
"path": "/api/v1/public/status"
},
{
"method": "GET",
"path": "/api/v1/public/teams"
},
{
"method": "GET",
"path": "/api/v1/public/teams/:slug"
},
{
"method": "GET",
"path": "/api/v1/public/teams/:slug/members"
},
{
"method": "GET",
"path": "/api/v1/public/version"

View File

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,296 @@
// ── The Team read model ────────────────────────────────────────────────────
//
// What the three API tiers are allowed to see (TEAMS.md §2.11), assembled from
// the projection, the resolver and the sync state.
//
// **Two rules shape every function here.**
//
// 1. *Hidden means absent from every public surface* (§2.8.3) — the index, the
// lookup, the roster. Not archived, not deleted, and completely functional for
// its own members. A hidden Team that 404s publicly but answers for a member
// is the intended behaviour, not an inconsistency.
//
// 2. *Staleness is surfaced, never silent* (§2.4). Every public payload carries
// `{ stale, lastSyncAt }`, so a page can say "roster last confirmed 14 minutes
// ago" rather than presenting a stale roster as current. A projection nobody
// can tell is stale is worse than one that is obviously old.
//
// The per-audience FIELD projection of a roster row is the module's, not core's
// (§10.5, §3.3) — the visibility framework and its config are module-owned. This
// phase serves a conservative core projection: a public roster carries in-game
// display names and never a site account id or a game member key. The module's
// rung-aware projection lands with the Team pages in phase 3.
const teamsDb = require('./teams.db')
const teamProvider = require('./teamProvider')
const access = require('./teamAccess.model')
const teamSync = require('./teamSync.model')
// Past this multiple of the poll interval a projection is reported stale. Two
// intervals rather than one, so an ordinary late poll does not make every page
// cry wolf — the threshold has to mean "something is wrong", not "a run is due".
const STALE_INTERVALS = 2
/** The public shape of a Team. Deliberately small. */
function publicTeam(row) {
return {
slug: row.slug,
// What is DISPLAYED may have been overridden by staff; what the row IS never
// changes (§2.2, §2.8.3). Public callers only ever see the former.
name: row.display_name_override || row.name,
abbr: row.abbr,
memberCount: row.member_count,
linkedCount: row.linked_count,
onlineCount: row.online_count,
meta: row.meta ?? null,
status: row.status,
createdAt: row.created_at,
rosterSyncedAt: row.roster_synced_at,
...(row.status === 'archived' ? { archivedAt: row.archived_at, archivedReason: row.archived_reason } : {}),
}
}
/**
* The public shape of a roster row.
*
* `member_key` and `user_id` are both withheld: the first is a game-internal
* identifier and the second names a site account. `linked` answers the only
* question a public page has — whether this character has an account behind it —
* without publishing which one.
*/
function publicMember(row) {
return {
displayName: row.display_name,
rankLabel: row.rank_label,
isLeader: Boolean(row.is_leader),
online: Boolean(row.online),
linked: row.user_id != null,
}
}
/** The admin shape: everything, including what a decision overrode. */
function adminTeam(row) {
return {
id: row.id,
moduleId: row.module_id,
externalId: row.external_id,
slug: row.slug,
name: row.name,
displayName: row.display_name_override || row.name,
displayNameOverride: row.display_name_override,
abbr: row.abbr,
status: row.status,
hidden: Boolean(row.hidden),
hiddenReason: row.hidden_reason,
hiddenTerm: row.hidden_term,
nameReviewedAt: row.name_reviewed_at,
memberCount: row.member_count,
linkedCount: row.linked_count,
onlineCount: row.online_count,
rosterSyncedAt: row.roster_synced_at,
membersEmptySince: row.members_empty_since,
succeededBy: row.succeeded_by,
createdAt: row.created_at,
archivedAt: row.archived_at,
archivedReason: row.archived_reason,
meta: row.meta ?? null,
}
}
function adminMember(row) {
return {
memberKey: row.member_key,
displayName: row.display_name,
userId: row.user_id,
rankLabel: row.rank_label,
isLeader: Boolean(row.is_leader),
isLeaderSynced: Boolean(row.is_leader_synced),
leaderOverride: row.leader_override || null,
online: Boolean(row.online),
status: row.status,
firstSeenAt: row.first_seen_at,
lastSeenAt: row.last_seen_at,
departedAt: row.departed_at,
}
}
/**
* Freshness, as every public payload reports it.
*
* With no provider registered there is nothing to be stale ABOUT, so this reports
* `stale: false` and a null timestamp rather than "very stale" — a deployment
* with no game module is not a broken one.
*/
async function syncStatus() {
const moduleId = teamProvider.providerModuleId()
if (!moduleId) return { stale: false, lastSyncAt: null, configured: false }
const [state, intervalS] = await Promise.all([
teamsDb.syncState(moduleId),
teamSync.intervalSeconds(),
])
const lastSyncAt = state ? state.last_success_at : null
const ageS = lastSyncAt ? (Date.now() - new Date(lastSyncAt).getTime()) / 1000 : Infinity
return {
configured: true,
lastSyncAt,
// Never synced at all is stale: a page must not present an empty projection
// as a confirmed empty shard.
stale: ageS > intervalS * STALE_INTERVALS,
consecutiveFailures: state ? state.consecutive_failures : 0,
}
}
// ── Public ─────────────────────────────────────────────────────────────────
async function listPublic({ limit = 50, offset = 0 } = {}) {
// Every active Team, not just the registered provider's. The rows are core's
// and they outlive the module that filled them: keying the index on a provider
// made an uninstalled module's Teams vanish from /teams while
// /teams/:slug/members still served them in full, because the lookup goes by
// slug. `configured: false` is how a client learns the projection is no longer
// being maintained -- an empty list would have said something untrue instead.
const [rows, sync] = await Promise.all([teamsDb.allActive(), syncStatus()])
const visible = rows.filter((r) => !r.hidden)
return {
teams: visible.slice(offset, offset + limit).map(publicTeam),
total: visible.length,
...sync,
}
}
/**
* One Team by slug, for a public caller.
*
* An ARCHIVED Team resolves rather than 404ing (§2.2): a bookmark or a Discord
* link from before a rename must land somewhere that explains itself. A HIDDEN
* one does not resolve at all — that is the difference between retired and
* suppressed.
*/
async function getPublic(slug) {
const row = await teamsDb.findBySlug(slug)
if (!row || row.hidden) return null
const sync = await syncStatus()
const successor = row.succeeded_by ? await teamsDb.findById(row.succeeded_by) : null
return {
...publicTeam(row),
...sync,
successor: successor && !successor.hidden
? { slug: successor.slug, name: successor.display_name_override || successor.name }
: null,
}
}
async function rosterPublic(slug) {
const row = await teamsDb.findBySlug(slug)
if (!row || row.hidden) return null
const [members, sync] = await Promise.all([
access.rosterWithOverrides(row.id),
syncStatus(),
])
return { members: members.map(publicMember), ...sync, rosterSyncedAt: row.roster_synced_at }
}
// ── Player ─────────────────────────────────────────────────────────────────
/**
* The caller's Teams — membership and grants — each with the REASON it is listed.
*
* The two are read from their own tables and merged here rather than by a query
* that unions them, so the reason survives into the payload. `both` is a real
* state and the UI needs it: a member who also holds a historical grant should
* see membership as the current reason without the grant vanishing.
*
* A hidden Team IS listed here. Suppression is a public-surface rule; a member is
* not a member of the public.
*/
async function listForUser(userId) {
const memberships = await teamsDb.activeTeamsForUser(userId)
const byId = new Map()
for (const row of memberships) {
byId.set(row.id, { ...publicTeam(row), reason: 'membership', isLeader: Boolean(row.is_leader) })
}
// Grants are per Team, so the visible set is walked rather than queried the
// other way round; the population is small (a user's Teams), and it keeps path
// 3's read on path 3's table.
const all = await teamsDb.allActive()
for (const row of all) {
// eslint-disable-next-line no-await-in-loop
const resolved = await access.forumAccess(row.id, userId)
if (!resolved.viaGrant) continue
const existing = byId.get(row.id)
if (existing) existing.reason = 'both'
else byId.set(row.id, { ...publicTeam(row), reason: 'grant', isLeader: false })
}
return { teams: [...byId.values()], ...(await syncStatus()) }
}
/** The caller's own resolved access on one Team. */
async function accessForUser(slug, userId) {
const row = await teamsDb.findBySlug(slug)
if (!row) return null
const resolved = await access.forumAccess(row.id, userId)
return { slug: row.slug, ...resolved }
}
// ── Admin ──────────────────────────────────────────────────────────────────
async function listAdmin({ includeArchived = false } = {}) {
const moduleId = teamProvider.providerModuleId()
const rows = await teamsDb.allActive()
const sync = await syncStatus()
const state = moduleId ? await teamsDb.syncState(moduleId) : null
return {
teams: rows.map(adminTeam),
...sync,
// Shown verbatim on Admin → Teams, including the last error: an operator
// debugging a stale projection needs what the provider actually said.
syncState: state
? {
moduleId: state.module_id,
lastAttemptAt: state.last_attempt_at,
lastSuccessAt: state.last_success_at,
consecutiveFailures: state.consecutive_failures,
lastError: state.last_error,
pendingEmptySince: state.pending_empty_since,
}
: null,
includeArchived,
}
}
async function getAdmin(id) {
const row = await teamsDb.findById(id)
if (!row) return null
const [members, grants, pending] = await Promise.all([
access.rosterWithOverrides(row.id, { includeDeparted: true }),
access.grantLedger(row.id),
// eslint-disable-next-line global-require
require('./teamModeration.model').pendingForTeam(row.id),
])
return {
...adminTeam(row),
members: members.map(adminMember),
grants,
pendingRequests: pending,
}
}
module.exports = {
listPublic,
getPublic,
rosterPublic,
listForUser,
accessForUser,
listAdmin,
getAdmin,
syncStatus,
publicTeam,
publicMember,
adminTeam,
adminMember,
STALE_INTERVALS,
}

View File

@@ -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`. */

View File

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

View File

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

View File

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

View File

@@ -31,6 +31,7 @@ const emailRouter = require('./email.router')
const discordBotRouter = require('./discordBot.router')
const settingsRouter = require('./settings.router')
const modulesRouter = require('./modules.router')
const teamsRouter = require('./teams.router')
const dashboardRouter = require('./dashboard.router')
const adminRouter = express.Router()
@@ -79,6 +80,11 @@ adminRouter.use('/settings', settingsRouter)
// here alongside the other configuration capabilities, and admin-only per route
// rather than at this line, so the gate sits next to what it is guarding.
adminRouter.use('/modules', modulesRouter)
// Teams. Staff-wide, like /activity: a moderator runs the reserved-name review
// queue. The three actions that PUBLISH untrusted game-sourced strings are gated
// per request inside the controller, not per route — a moderator may call them,
// and calling them files a request rather than applying one (TEAMS.md §2.9).
adminRouter.use('/teams', teamsRouter)
// The two singletons that own no path segment of their own: GET /dashboard and
// PUT /site-mode. Mounted at the group root, last, exactly where the residual

View File

@@ -0,0 +1,211 @@
// Admin · Teams — the staff surface (TEAMS.md §2.11).
//
// The role split inside this file is the §2.9 gate, and it is enforced HERE
// rather than in the router, because it is not a matter of which routes a role
// may call: a moderator may call all of them, and three of them mean something
// different when they do. `requestOrApply` is what decides, from the caller's
// live role, whether an action applies or is filed for approval.
const teams = require('../../../model/teams/teams.model')
const moderation = require('../../../model/teams/teamModeration.model')
const access = require('../../../model/teams/teamAccess.model')
const teamSync = require('../../../model/teams/teamSync.model')
const teamsDb = require('../../../model/teams/teams.db')
const activity = require('../../../model/activity/activity.model')
const log = require('../../../utils/logger')('teams')
const fail = (res, err, what) => {
log.error(`admin teams: ${what} failed`, { message: err.message })
return res.status(500).json({ message: 'Internal Server Error' })
}
/** Translate a model result's { ok, status, error } into a response. */
const send = (res, result, body = { ok: true }) =>
(result.ok ? res.json({ ...body, ...result }) : res.status(result.status || 400).json({ message: result.error }))
async function listTeams(req, res) {
try {
return res.json(await teams.listAdmin({ includeArchived: req.query.archived === '1' }))
} catch (err) {
return fail(res, err, 'list')
}
}
async function getTeam(req, res) {
try {
const team = await teams.getAdmin(Number(req.params.id))
if (!team) return res.status(404).json({ message: 'Team not found' })
return res.json(team)
} catch (err) {
return fail(res, err, 'get')
}
}
/**
* The operator's escape hatch.
*
* Awaited rather than fire-and-forget: someone who pressed a button is owed the
* outcome, including the provider's error when it refused. `ctx.teams.reconcile()`
* is the debounced, unawaited path — this is not that.
*/
async function resync(req, res) {
try {
const result = await teamSync.reconcileNow('admin')
await activity.log({ req, action: 'team.resync', detail: `${req.user.username} (#${req.user.id}) ran a Team resync` })
return res.json(result)
} catch (err) {
return fail(res, err, 'resync')
}
}
async function archive(req, res) {
try {
const id = Number(req.params.id)
const team = await teamsDb.findById(id)
if (!team) return res.status(404).json({ message: 'Team not found' })
await teamsDb.archiveTeam(id, 'staff')
await activity.log({
req,
action: 'team.archive',
detail: `${req.user.username} (#${req.user.id}) archived team "${team.name}" (#${id})`
+ `${req.body.reason ? `: "${req.body.reason}"` : ''}`,
})
return res.json({ ok: true })
} catch (err) {
return fail(res, err, 'archive')
}
}
async function grants(req, res) {
try {
return res.json({ grants: await access.grantLedger(Number(req.params.id)) })
} catch (err) {
return fail(res, err, 'grants')
}
}
// ── Leadership overrides (§2.5.1) — NOT gated ─────────────────────────────
async function setLeaderOverride(req, res) {
try {
const id = Number(req.params.id)
const team = await teamsDb.findById(id)
if (!team) return res.status(404).json({ message: 'Team not found' })
const { memberKey, effect, reason } = req.body
await access.setLeaderOverride({
teamId: id,
memberKey,
effect,
actorUserId: req.user.id,
actorUsername: req.user.username,
reason: reason || null,
})
await activity.log({
req,
action: 'team.leader.override',
detail: `${req.user.username} (#${req.user.id}) set a "${effect}" leadership override on `
+ `${memberKey} in team "${team.name}" (#${id})${reason ? `: "${reason}"` : ''}`,
})
return res.json({ ok: true })
} catch (err) {
return fail(res, err, 'leader-override')
}
}
async function clearLeaderOverride(req, res) {
try {
const id = Number(req.params.id)
const removed = await access.clearLeaderOverride(id, req.params.memberKey)
if (!removed) return res.status(404).json({ message: 'No such override' })
await activity.log({
req,
action: 'team.leader.override',
detail: `${req.user.username} (#${req.user.id}) cleared the leadership override on `
+ `${req.params.memberKey} in team #${id}`,
})
return res.json({ ok: true })
} catch (err) {
return fail(res, err, 'leader-override')
}
}
// ── The three gated actions, plus the ungated hide (§2.9) ─────────────────
async function unhide(req, res) {
try {
return send(res, await moderation.requestOrApply({
req, actor: req.user, teamId: Number(req.params.id), action: 'unhide', reason: req.body.reason,
}))
} catch (err) {
return fail(res, err, 'unhide')
}
}
async function hide(req, res) {
try {
return send(res, await moderation.hide({
req, actor: req.user, teamId: Number(req.params.id), reason: req.body.reason,
}))
} catch (err) {
return fail(res, err, 'hide')
}
}
async function displayName(req, res) {
try {
const { displayName: value, reason } = req.body
// An empty string is how a UI says "clear it", and clearing is its own gated
// action rather than an override set to nothing — otherwise the audit line
// would read as though someone published a blank name.
const action = value ? 'display_name_override' : 'clear_display_name_override'
return send(res, await moderation.requestOrApply({
req, actor: req.user, teamId: Number(req.params.id), action, payload: { displayName: value || null }, reason,
}))
} catch (err) {
return fail(res, err, 'display-name')
}
}
async function reviewQueue(req, res) {
try {
return res.json({ teams: await moderation.reviewQueue() })
} catch (err) {
return fail(res, err, 'review queue')
}
}
async function listRequests(req, res) {
try {
return res.json({ requests: await moderation.listRequests({ status: req.query.status || 'pending' }) })
} catch (err) {
return fail(res, err, 'requests')
}
}
async function decideRequest(req, res) {
try {
return send(res, await moderation.decide({
req, actor: req.user, requestId: Number(req.params.id), status: req.body.status, note: req.body.note,
}))
} catch (err) {
return fail(res, err, 'decide')
}
}
module.exports = {
listTeams,
getTeam,
resync,
archive,
grants,
setLeaderOverride,
clearLeaderOverride,
unhide,
hide,
displayName,
reviewQueue,
listRequests,
decideRequest,
}

View File

@@ -0,0 +1,223 @@
// Admin · Teams — sync state, the review queue, the approval queue, and the staff
// actions on a Team (TEAMS.md §2.11).
//
// Mounted at /api/v1/admin/teams by admin/index.js, which already applied
// `noindex, isLoggedIn, staffOnly`. Staff-wide, like /admin/activity: a moderator
// runs the review queue, and the three actions that PUBLISH untrusted
// game-sourced strings are gated per request inside the controller rather than
// per route here — a moderator may call them, and calling them files a request
// instead of applying one.
//
// **Declaration order matters in this file.** `/review`, `/requests` and `/resync`
// are literal paths that would otherwise be captured by `/:id`, so every literal
// route is declared before the first :param route. Express is first-match-wins and
// a `/:id` ahead of `/review` would silently turn a queue into a lookup for a Team
// whose id is "review".
const express = require('express')
const { body, param, query } = require('express-validator')
const ctrl = require('./teams.controller')
const validate = require('../../../middleware/validate')
const teamsRouter = express.Router()
// ── Literal paths, first ───────────────────────────────────────────────────
teamsRouter.get(
'/',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'List Teams with sync state'
// #swagger.description = 'Includes hidden Teams and the modules sync state verbatim — last attempt, last success, consecutive failures and the last error — which is what an operator debugging a stale projection needs.'
// #swagger.parameters['archived'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Set to 1 to include archived Teams.' }
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Teams and sync state', content: { "application/json": { schema: { $ref: "#/components/schemas/AdminTeamList" } } } } */
query('archived').optional().isIn(['0', '1']),
validate,
ctrl.listTeams,
)
teamsRouter.post(
'/resync',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'Run a reconciliation now'
// #swagger.description = 'Awaited, so the response carries the outcome including the providers own error when it refused. The four refusal gates still apply — a manual resync cannot make core act on an answer it does not trust.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The reconciliation result', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamResyncResult" } } } } */
ctrl.resync,
)
teamsRouter.get(
'/review',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'The reserved-name review queue'
// #swagger.description = 'Teams auto-hidden because their name matched a reserved term, each showing which term matched. A Team a human has already ruled on leaves the queue and is never re-hidden by a later sweep.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Auto-hidden Teams awaiting review', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamReviewQueue" } } } } */
ctrl.reviewQueue,
)
teamsRouter.get(
'/requests',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'The moderation approval queue'
// #swagger.description = 'Requests filed by moderators for the three actions that publish untrusted game-sourced strings. Decided rows are kept — the record that a moderator asked to publish a name and an admin refused is the part worth having.'
// #swagger.parameters['status'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'pending (default) | approved | rejected | withdrawn | all' }
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Moderation requests', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamRequestQueue" } } } } */
query('status').optional().isIn(['pending', 'approved', 'rejected', 'withdrawn', 'all']),
validate,
ctrl.listRequests,
)
teamsRouter.post(
'/requests/:id/decide',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'Approve or reject a moderation request (admin only)'
// #swagger.description = 'Admin only, checked live against the database rather than from a token claim. Approving applies the action; rejecting keeps the row and changes nothing. A request already decided returns 409, so two admins deciding at once cannot double-apply.'
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Request id.' }
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamDecideRequest" } } } } */
/* #swagger.responses[200] = { description: 'Decided', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[403] = { description: 'Only an admin may decide a request', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'No such request', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'Already decided', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt({ min: 1 }).toInt(),
body('status').isIn(['approved', 'rejected']),
body('note').optional().isString().trim().isLength({ max: 255 }),
validate,
ctrl.decideRequest,
)
// ── :id paths ──────────────────────────────────────────────────────────────
teamsRouter.get(
'/:id',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'Get one Team, with its roster, grant ledger and pending requests'
// #swagger.description = 'The roster carries the resolved leadership and what the game actually said, so an override is visible as a decision rather than presented as fact. Departed members are included.'
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' }
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The Team', content: { "application/json": { schema: { $ref: "#/components/schemas/AdminTeam" } } } } */
/* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt({ min: 1 }).toInt(),
validate,
ctrl.getTeam,
)
teamsRouter.get(
'/:id/grants',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'The full forum-grant ledger for a Team, revoked rows included'
// #swagger.description = 'The structured record the access resolver reads. The grant/revoke flow itself lands in the forum phase; this is the read side.'
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' }
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The grant ledger', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamGrantLedger" } } } } */
param('id').isInt({ min: 1 }).toInt(),
validate,
ctrl.grants,
)
teamsRouter.post(
'/:id/archive',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'Archive a Team (staff)'
// #swagger.description = 'Not gated: archiving withdraws a Team from public surfaces rather than publishing anything.'
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' }
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamReasonRequest" } } } } */
/* #swagger.responses[200] = { description: 'Archived', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */
/* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt({ min: 1 }).toInt(),
body('reason').optional().isString().trim().isLength({ max: 255 }),
validate,
ctrl.archive,
)
teamsRouter.post(
'/:id/hide',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'Hide a Team from public surfaces (staff)'
// #swagger.description = 'Deliberately NOT gated. Publishing untrusted data needs a second pair of eyes; withdrawing it needs to be possible at once, by whoever is on duty.'
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' }
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamReasonRequest" } } } } */
/* #swagger.responses[200] = { description: 'Hidden', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */
/* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt({ min: 1 }).toInt(),
body('reason').optional().isString().trim().isLength({ max: 255 }),
validate,
ctrl.hide,
)
teamsRouter.post(
'/:id/unhide',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'Un-hide a Team — admin applies, moderator requests'
// #swagger.description = 'One of the three gated actions: it publishes a name that tripped the impersonation list. An admin applies it at once; a moderator files a pending request and nothing changes publicly until an admin approves.'
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' }
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamReasonRequest" } } } } */
/* #swagger.responses[200] = { description: 'Applied, or filed for approval — see `pending`', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamModerationResult" } } } } */
/* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt({ min: 1 }).toInt(),
body('reason').optional().isString().trim().isLength({ max: 255 }),
validate,
ctrl.unhide,
)
teamsRouter.post(
'/:id/display-name',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'Set or clear a Teams display name — admin applies, moderator requests'
// #swagger.description = 'Gated for the same reason as un-hiding: it substitutes free text into the same public surfaces. Identity is untouched — the Teams `name` stays frozen for the life of the row, and only what is rendered changes. An empty displayName clears the override.'
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' }
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamDisplayNameRequest" } } } } */
/* #swagger.responses[200] = { description: 'Applied, or filed for approval — see `pending`', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamModerationResult" } } } } */
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt({ min: 1 }).toInt(),
body('displayName').optional({ nullable: true }).isString().trim().isLength({ max: 160 }),
body('reason').optional().isString().trim().isLength({ max: 255 }),
validate,
ctrl.displayName,
)
teamsRouter.post(
'/:id/leader-override',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'Grant or deny leadership for one member (staff)'
// #swagger.description = 'Applied on top of the synced value at READ time; the projection is never mutated. That is what makes an override survive a resync — one written into team_members would be undone by the next reconciliation. Not gated: it publishes no game-sourced string.'
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' }
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamLeaderOverrideRequest" } } } } */
/* #swagger.responses[200] = { description: 'Override set', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
/* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt({ min: 1 }).toInt(),
body('memberKey').isString().trim().isLength({ min: 1, max: 191 }),
body('effect').isIn(['grant', 'deny']),
body('reason').optional().isString().trim().isLength({ max: 255 }),
validate,
ctrl.setLeaderOverride,
)
teamsRouter.delete(
'/:id/leader-override/:memberKey',
// #swagger.tags = ['Admin · Teams']
// #swagger.summary = 'Clear a leadership override (staff)'
// #swagger.description = 'The member reverts to whatever the game says at the next read; nothing in the projection changes, because nothing in it was ever changed.'
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' }
// #swagger.parameters['memberKey'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The modules member key.' }
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Override cleared', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */
/* #swagger.responses[404] = { description: 'No such override', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt({ min: 1 }).toInt(),
param('memberKey').isString().trim().isLength({ min: 1, max: 191 }),
validate,
ctrl.clearLeaderOverride,
)
module.exports = teamsRouter

View File

@@ -26,6 +26,7 @@ const noindex = require('../../../middleware/noindex')
const accountRouter = require('./account.router')
const appealsRouter = require('./appeals.router')
const teamsRouter = require('./teams.router')
const playerRouter = express.Router()
@@ -39,5 +40,6 @@ playerRouter.use(noindex, requireAuth)
playerRouter.use('/account', accountRouter)
playerRouter.use('/appeals', appealsRouter)
playerRouter.use('/teams', teamsRouter)
module.exports = playerRouter

View File

@@ -0,0 +1,28 @@
// Player · Teams — self-scoped reads. Neither handler takes an identity from the
// caller; both use req.user.id, which the tier's requireAuth has already proved.
const teams = require('../../../model/teams/teams.model')
const log = require('../../../utils/logger')('teams')
async function listMine(req, res) {
try {
return res.json(await teams.listForUser(req.user.id))
} catch (err) {
log.error('player teams: list failed', { message: err.message })
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function getMyAccess(req, res) {
try {
const resolved = await teams.accessForUser(req.params.slug, req.user.id)
if (!resolved) return res.status(404).json({ message: 'Team not found' })
return res.json(resolved)
} catch (err) {
log.error('player teams: access failed', { message: err.message })
return res.status(500).json({ message: 'Internal Server Error' })
}
}
module.exports = { listMine, getMyAccess }

View File

@@ -0,0 +1,46 @@
// Player · Teams — the caller's own Teams and their own access on one.
//
// Mounted at /api/v1/player/teams by player/index.js, which already applied
// `noindex, requireAuth`. No extra gate: both handlers are self-scoped to
// req.user.id and neither takes a user id from the caller.
//
// **Staff are a superset of players.** This group is open to any authenticated
// account, not just role 'player' — a moderator is in guilds too, and gating on
// the role would 403 them off their own Teams. That mistake has been made here
// once already (see player/index.js).
//
// Leader-exercised actions — granting forum access — land in phase 4 and will
// live under this same prefix rather than under /admin: a leader is a player, and
// the /admin tier gate is requireRole('admin','editor','moderator'), so putting a
// leader endpoint behind it would mean widening that gate.
const express = require('express')
const ctrl = require('./teams.controller')
const teamsRouter = express.Router()
teamsRouter.get(
'/',
// #swagger.tags = ['Player · Teams']
// #swagger.summary = 'List the callers Teams, with the reason for each'
// #swagger.description = 'Membership and forum grants are separate authority paths, so each Team carries `reason`: membership | grant | both. A Team hidden from public surfaces is still listed here — suppression is a public-surface rule, and a member is not a member of the public.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The callers Teams', content: { "application/json": { schema: { $ref: "#/components/schemas/PlayerTeamList" } } } } */
/* #swagger.responses[403] = { description: 'Account not active (disabled/banned)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
ctrl.listMine,
)
teamsRouter.get(
'/:slug/access',
// #swagger.tags = ['Player · Teams']
// #swagger.summary = 'The callers own resolved access on one Team'
// #swagger.description = 'Reports viaMembership and viaGrant separately, and keeps both when both hold: the UI presents membership as the current reason while the grant survives as audit history.'
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The callers access', content: { "application/json": { schema: { $ref: "#/components/schemas/PlayerTeamAccess" } } } } */
/* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
ctrl.getMyAccess,
)
module.exports = teamsRouter

View File

@@ -21,6 +21,7 @@ const postsRouter = require('./posts.router')
const wikiRouter = require('./wiki.router')
const pagesRouter = require('./pages.router')
const modulesRouter = require('./modules.router')
const teamsRouter = require('./teams.router')
const siteRouter = require('./site.router')
const publicRouter = express.Router()
@@ -36,6 +37,10 @@ publicRouter.use('/pages', pagesRouter)
// /modules unclaimable by a module. Never site-mode gated: a client must be able
// to feature-detect while the site is in maintenance.
publicRouter.use('/modules', modulesRouter)
// Teams. A core prefix, not a module's: the entity is core's even though a module
// is what populates it (TEAMS.md §10.3). Site-mode gated per route, like the
// content above it.
publicRouter.use('/teams', teamsRouter)
// The four singletons that own no path segment of their own: /settings, /status,
// /version and /contact. Mounted at the group root, last — safe only because

View File

@@ -0,0 +1,48 @@
// Public · Teams — the anonymous read surface (TEAMS.md §2.11).
//
// Every handler here is a projection over core's own tables; nothing calls the
// module. A Team page must render while the shard is down, showing a roster
// marked stale, because that is what the projection is for.
const teams = require('../../../model/teams/teams.model')
const log = require('../../../utils/logger')('teams')
const fail = (res, err, what) => {
log.error(`public teams: ${what} failed`, { message: err.message })
return res.status(500).json({ message: 'Internal Server Error' })
}
async function listTeams(req, res) {
try {
const limit = Math.min(Number.parseInt(req.query.limit, 10) || 50, 200)
const offset = Math.max(Number.parseInt(req.query.offset, 10) || 0, 0)
return res.json(await teams.listPublic({ limit, offset }))
} catch (err) {
return fail(res, err, 'list')
}
}
async function getTeam(req, res) {
try {
const team = await teams.getPublic(req.params.slug)
// A hidden Team is indistinguishable from a missing one here, deliberately:
// "absent from every public surface" includes not confirming it exists.
if (!team) return res.status(404).json({ message: 'Team not found' })
return res.json(team)
} catch (err) {
return fail(res, err, 'get')
}
}
async function getRoster(req, res) {
try {
const roster = await teams.rosterPublic(req.params.slug)
if (!roster) return res.status(404).json({ message: 'Team not found' })
return res.json(roster)
} catch (err) {
return fail(res, err, 'roster')
}
}
module.exports = { listTeams, getTeam, getRoster }

View File

@@ -0,0 +1,54 @@
// Public · Teams — the anonymous Team surface (TEAMS.md §2.11).
//
// Mounted at /api/v1/public/teams by public/index.js. No group gate: this is the
// anonymous surface, and `siteMode` is applied per route as everywhere else in
// this tier — during maintenance only an admin with a valid session sees content.
//
// Declaration order: '/' is literal and precedes the two :slug routes, and
// '/:slug/members' is deeper than '/:slug', so nothing here can shadow anything
// else.
const express = require('express')
const ctrl = require('./teams.controller')
const siteMode = require('../../../middleware/siteMode')
const teamsRouter = express.Router()
teamsRouter.get(
'/',
// #swagger.tags = ['Public · Teams']
// #swagger.summary = 'List active, publicly visible Teams'
// #swagger.description = 'Teams hidden by reserved-name screening or by staff are absent. The response carries { stale, lastSyncAt } so a client can say how recently the projection was confirmed against the game.'
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Page size, max 200 (default 50).' }
// #swagger.parameters['offset'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Rows to skip (default 0).' }
/* #swagger.responses[200] = { description: 'Publicly visible Teams, with sync freshness', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicTeamList" } } } } */
siteMode,
ctrl.listTeams,
)
teamsRouter.get(
'/:slug',
// #swagger.tags = ['Public · Teams']
// #swagger.summary = 'Get one Team by slug'
// #swagger.description = 'An archived Team still resolves, read-only, and names its successor when it was renamed — an old bookmark or Discord link lands somewhere that explains itself. A hidden Team returns 404, indistinguishable from one that does not exist.'
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
/* #swagger.responses[200] = { description: 'The Team', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicTeam" } } } } */
/* #swagger.responses[404] = { description: 'No such Team, or it is hidden', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
siteMode,
ctrl.getTeam,
)
teamsRouter.get(
'/:slug/members',
// #swagger.tags = ['Public · Teams']
// #swagger.summary = 'Get a Team roster'
// #swagger.description = 'In-game display names only. A member key is a game-internal identifier and a user id names a site account; neither is published. `linked` answers whether a character has an account behind it without saying which.'
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
/* #swagger.responses[200] = { description: 'The roster, with sync freshness', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicTeamRoster" } } } } */
/* #swagger.responses[404] = { description: 'No such Team, or it is hidden', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
siteMode,
ctrl.getRoster,
)
module.exports = teamsRouter

View 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

View File

@@ -927,6 +927,310 @@ const doc = {
removed: { type: 'boolean', description: 'Whether the IP had an entry that was cleared.', example: true },
},
},
// ── Teams (docs/website/TEAMS.md) ────────────────────────────────────
OkResponse: {
type: 'object',
properties: { ok: { type: 'boolean', example: true } },
},
TeamSyncFreshness: {
type: 'object',
description:
'Freshness of core\'s projection of the game\'s Teams. Carried on every public Team payload so a page can say how recently the roster was confirmed rather than presenting stale data as current. `configured` is false when no module supplies a Team provider — a deployment with no game module is not a stale one.',
properties: {
configured: { type: 'boolean', example: true },
stale: {
type: 'boolean',
description: 'True past twice the reconcile interval, or when the projection has never synced at all.',
example: false,
},
lastSyncAt: { type: 'string', format: 'date-time', nullable: true },
consecutiveFailures: { type: 'integer', example: 0 },
},
},
PublicTeam: {
type: 'object',
description:
'A Team as an anonymous caller sees it. `name` is what is DISPLAYED — a staff display-name override, when one is set — never the frozen identity behind it.',
properties: {
slug: { type: 'string', example: 'the-silver-hand' },
name: { type: 'string', example: 'The Silver Hand' },
abbr: { type: 'string', nullable: true, example: 'TSH' },
memberCount: { type: 'integer', example: 42 },
linkedCount: { type: 'integer', description: 'Members with a linked site account.', example: 11 },
onlineCount: { type: 'integer', example: 3 },
meta: { type: 'object', nullable: true, additionalProperties: true, description: 'Module-supplied and opaque to core.' },
status: { type: 'string', enum: ['active', 'archived'] },
createdAt: { type: 'string', format: 'date-time' },
rosterSyncedAt: { type: 'string', format: 'date-time', nullable: true },
archivedAt: { type: 'string', format: 'date-time', nullable: true },
archivedReason: { type: 'string', nullable: true, example: 'renamed' },
successor: {
type: 'object',
nullable: true,
description: 'Where an archived Team continued after a rename, so an old link explains itself instead of 404ing.',
properties: { slug: { type: 'string' }, name: { type: 'string' } },
},
},
},
PublicTeamList: {
type: 'object',
allOf: [{ $ref: '#/components/schemas/TeamSyncFreshness' }],
properties: {
teams: { type: 'array', items: { $ref: '#/components/schemas/PublicTeam' } },
total: { type: 'integer', example: 12 },
},
},
PublicTeamMember: {
type: 'object',
description:
'A roster row as an anonymous caller sees it. The member key is a game-internal identifier and the user id names a site account; neither is published. `linked` answers whether a character has an account behind it without saying which.',
properties: {
displayName: { type: 'string', nullable: true, example: 'Aldric' },
rankLabel: { type: 'string', nullable: true, description: 'Module vocabulary, opaque to core.', example: 'Warlord' },
isLeader: { type: 'boolean', example: true },
online: { type: 'boolean', example: false },
linked: { type: 'boolean', example: true },
},
},
PublicTeamRoster: {
type: 'object',
allOf: [{ $ref: '#/components/schemas/TeamSyncFreshness' }],
properties: {
members: { type: 'array', items: { $ref: '#/components/schemas/PublicTeamMember' } },
rosterSyncedAt: { type: 'string', format: 'date-time', nullable: true },
},
},
PlayerTeamList: {
type: 'object',
allOf: [{ $ref: '#/components/schemas/TeamSyncFreshness' }],
properties: {
teams: {
type: 'array',
items: {
allOf: [{ $ref: '#/components/schemas/PublicTeam' }],
type: 'object',
properties: {
reason: {
type: 'string',
enum: ['membership', 'grant', 'both'],
description: 'Which authority path lists this Team for the caller. `both` is a real state and is kept: membership is the current reason while the grant survives as audit history.',
},
isLeader: { type: 'boolean' },
},
},
},
},
},
PlayerTeamAccess: {
type: 'object',
properties: {
slug: { type: 'string' },
allowed: { type: 'boolean' },
viaMembership: { type: 'boolean' },
viaGrant: { type: 'boolean', description: 'Reported even when membership also holds.' },
isLeader: { type: 'boolean', description: 'The synced value with any staff override applied.' },
},
},
AdminTeam: {
type: 'object',
description: 'The full staff view, including what a staff decision overrode.',
properties: {
id: { type: 'integer' },
moduleId: { type: 'string', example: 'uo' },
externalId: { type: 'string', description: 'The module\'s own stable id, opaque to core.' },
slug: { type: 'string' },
name: { type: 'string', description: 'The frozen identity. Immutable for the life of the row.' },
displayName: { type: 'string', description: 'What is rendered — the override when set, otherwise `name`.' },
displayNameOverride: { type: 'string', nullable: true },
abbr: { type: 'string', nullable: true },
status: { type: 'string', enum: ['active', 'archived'] },
hidden: { type: 'boolean' },
hiddenReason: { type: 'string', nullable: true, enum: ['reserved_name', 'staff', null] },
hiddenTerm: { type: 'string', nullable: true, description: 'Which reserved term matched.', example: 'admin' },
nameReviewedAt: { type: 'string', format: 'date-time', nullable: true, description: 'Set once a human has ruled on the name; a later sweep never re-hides it.' },
memberCount: { type: 'integer' },
linkedCount: { type: 'integer' },
onlineCount: { type: 'integer' },
rosterSyncedAt: { type: 'string', format: 'date-time', nullable: true },
membersEmptySince: { type: 'string', format: 'date-time', nullable: true, description: 'The per-Team empty-roster quarantine.' },
succeededBy: { type: 'integer', nullable: true },
createdAt: { type: 'string', format: 'date-time' },
archivedAt: { type: 'string', format: 'date-time', nullable: true },
archivedReason: { type: 'string', nullable: true },
meta: { type: 'object', nullable: true, additionalProperties: true },
members: { type: 'array', items: { $ref: '#/components/schemas/AdminTeamMember' } },
grants: { type: 'array', items: { $ref: '#/components/schemas/TeamGrant' } },
pendingRequests: { type: 'array', items: { $ref: '#/components/schemas/TeamModerationRequest' } },
},
},
AdminTeamMember: {
type: 'object',
properties: {
memberKey: { type: 'string', example: '0x40012ab3' },
displayName: { type: 'string', nullable: true },
userId: { type: 'integer', nullable: true, description: 'Resolved by the module; null means unlinked.' },
rankLabel: { type: 'string', nullable: true },
isLeader: { type: 'boolean', description: 'The resolved answer — synced value with any override applied.' },
isLeaderSynced: { type: 'boolean', description: 'What the game actually said, so an override reads as a decision rather than as fact.' },
leaderOverride: {
type: 'object',
nullable: true,
properties: {
effect: { type: 'string', enum: ['grant', 'deny'] },
reason: { type: 'string', nullable: true },
by: { type: 'string', nullable: true },
at: { type: 'string', format: 'date-time' },
},
},
online: { type: 'boolean' },
status: { type: 'string', enum: ['active', 'departed'] },
firstSeenAt: { type: 'string', format: 'date-time' },
lastSeenAt: { type: 'string', format: 'date-time' },
departedAt: { type: 'string', format: 'date-time', nullable: true },
},
},
AdminTeamList: {
type: 'object',
allOf: [{ $ref: '#/components/schemas/TeamSyncFreshness' }],
properties: {
teams: { type: 'array', items: { $ref: '#/components/schemas/AdminTeam' } },
syncState: {
type: 'object',
nullable: true,
description: 'The module\'s sync row verbatim, including the last error — what an operator debugging a stale projection needs.',
properties: {
moduleId: { type: 'string' },
lastAttemptAt: { type: 'string', format: 'date-time', nullable: true },
lastSuccessAt: { type: 'string', format: 'date-time', nullable: true },
consecutiveFailures: { type: 'integer' },
lastError: { type: 'string', nullable: true },
pendingEmptySince: { type: 'string', format: 'date-time', nullable: true },
},
},
},
},
TeamGrant: {
type: 'object',
description:
'One row of the append-only forum grant/revoke ledger. The username snapshots keep the record readable after an account is deleted — the ids go SET NULL, the audit trail does not.',
properties: {
id: { type: 'integer' },
team_id: { type: 'integer' },
user_id: { type: 'integer', nullable: true },
username: { type: 'string', nullable: true },
granted_by: { type: 'integer', nullable: true },
granted_username: { type: 'string', nullable: true },
granted_at: { type: 'string', format: 'date-time' },
reason: { type: 'string', nullable: true },
revoked_by: { type: 'integer', nullable: true },
revoked_username: { type: 'string', nullable: true },
revoked_at: { type: 'string', format: 'date-time', nullable: true },
revoke_reason: { type: 'string', nullable: true },
},
},
TeamGrantLedger: {
type: 'object',
properties: { grants: { type: 'array', items: { $ref: '#/components/schemas/TeamGrant' } } },
},
TeamModerationRequest: {
type: 'object',
properties: {
id: { type: 'integer' },
team_id: { type: 'integer' },
team_name: { type: 'string' },
team_slug: { type: 'string' },
action: { type: 'string', enum: ['unhide', 'display_name_override', 'clear_display_name_override'] },
payload: { type: 'object', nullable: true, additionalProperties: true },
reason: { type: 'string', nullable: true },
requested_by: { type: 'integer', nullable: true },
requested_username: { type: 'string', nullable: true },
requested_at: { type: 'string', format: 'date-time' },
status: { type: 'string', enum: ['pending', 'approved', 'rejected', 'withdrawn'] },
decided_by: { type: 'integer', nullable: true },
decided_username: { type: 'string', nullable: true },
decided_at: { type: 'string', format: 'date-time', nullable: true },
decision_note: { type: 'string', nullable: true },
},
},
TeamRequestQueue: {
type: 'object',
properties: { requests: { type: 'array', items: { $ref: '#/components/schemas/TeamModerationRequest' } } },
},
TeamReviewQueue: {
type: 'object',
description: 'Teams auto-hidden by reserved-name screening and not yet ruled on by a human.',
properties: {
teams: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'integer' },
name: { type: 'string', example: 'Admin' },
slug: { type: 'string' },
hidden_term: { type: 'string', example: 'admin' },
display_name_override: { type: 'string', nullable: true },
member_count: { type: 'integer' },
created_at: { type: 'string', format: 'date-time' },
},
},
},
},
},
TeamModerationResult: {
type: 'object',
description:
'The outcome of a gated action. `pending: true` means a moderator filed a request and nothing changed publicly; an admin\'s call applies at once and reports false.',
properties: {
ok: { type: 'boolean' },
pending: { type: 'boolean', example: false },
requestId: { type: 'integer', nullable: true },
},
},
TeamResyncResult: {
type: 'object',
description:
'A reconciliation outcome. `ok: false` carries the provider\'s own reason and means nothing was written. `quarantined` means an authoritative-but-empty answer was held back for confirmation rather than applied.',
properties: {
ok: { type: 'boolean' },
reason: { type: 'string', nullable: true },
quarantined: { type: 'boolean', nullable: true },
created: { type: 'integer', nullable: true },
renamed: { type: 'integer', nullable: true },
archived: { type: 'integer', nullable: true },
rosters: { type: 'integer', nullable: true, description: 'Rosters actually applied; a refused one is left untouched and not counted.' },
rehidden: { type: 'integer', nullable: true },
},
},
TeamReasonRequest: {
type: 'object',
properties: { reason: { type: 'string', maxLength: 255, example: 'impersonates staff' } },
},
TeamDisplayNameRequest: {
type: 'object',
properties: {
displayName: { type: 'string', nullable: true, maxLength: 160, description: 'Empty or null clears the override.', example: 'The Old Guard' },
reason: { type: 'string', maxLength: 255 },
},
},
TeamLeaderOverrideRequest: {
type: 'object',
required: ['memberKey', 'effect'],
properties: {
memberKey: { type: 'string', maxLength: 191, example: '0x40012ab3' },
effect: { type: 'string', enum: ['grant', 'deny'] },
reason: { type: 'string', maxLength: 255 },
},
},
TeamDecideRequest: {
type: 'object',
required: ['status'],
properties: {
status: { type: 'string', enum: ['approved', 'rejected'] },
note: { type: 'string', maxLength: 255 },
},
},
},
},
}

View File

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

View 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)
})

View 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)
})

View 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 moderators 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 requesters 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/)
})

View 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)
})

View File

@@ -0,0 +1,304 @@
// The Team API's access boundaries, exercised through the real routers
// (docs/website/TEAMS.md §2.11).
//
// The models are stubbed; what is under test is the wiring — which tier a route
// sits behind, what a hidden Team does to a public caller, and the one route that
// is admin-only inside a staff-wide group. Those are the properties a reviewer
// cannot check by reading a controller in isolation, because they are decided by
// the mount table and by a role read at request time.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, after, afterEach } = require('node:test')
const assert = require('node:assert/strict')
const { startApp } = require('./_helper')
const publicRouter = require('../src/router/v1/public')
const playerRouter = require('../src/router/v1/player')
const adminRouter = require('../src/router/v1/admin')
const sessionService = require('../src/auth/session.service')
const users = require('../src/model/users/users.model')
const teams = require('../src/model/teams/teams.model')
const teamsDbModule = require('../src/model/teams/teams.db')
const moderation = require('../src/model/teams/teamModeration.model')
const teamSync = require('../src/model/teams/teamSync.model')
const activity = require('../src/model/activity/activity.model')
const settings = require('../src/model/settings/settings.model')
const db = require('../src/utils/db')
after(() => db.close())
const saved = []
function patch(mod, name, fn) {
saved.push([mod, name, mod[name]])
mod[name] = fn
}
afterEach(() => {
while (saved.length) {
const [mod, name, fn] = saved.pop()
mod[name] = fn
}
})
function signInAs(user) {
patch(sessionService, 'validateSession', () => ({ userId: user.id, sessionId: 's1', createdAt: Date.now(), authMethod: 'jwt' }))
patch(sessionService, 'isSessionRevoked', async () => false)
patch(sessionService, 'sessionMeta', () => ({}))
patch(users, 'getById', async () => user)
// Every staff action writes the audit log, and the real one inserts a row. It
// swallows its own errors, so an unstubbed call is not a failure — it is ten
// seconds of connection retries against the dead pool, per test.
patch(activity, 'log', async () => {})
}
// siteMode reads settings; keep the public tier out of maintenance.
const liveSite = () => patch(settings, 'get', async () => 'live')
const admin = { id: 1, username: 'root', role: 'admin', status: 'active' }
const moderator = { id: 2, username: 'mod1', role: 'moderator', status: 'active' }
const player = { id: 3, username: 'ada', role: 'player', status: 'active' }
async function withApp(mountPath, router, fn) {
const app = await startApp((a) => a.use(mountPath, router))
try {
return await fn(app)
} finally {
await app.close()
}
}
const get = (app, path, init) => fetch(`${app.url}${path}`, init)
const post = (app, path, body) => fetch(`${app.url}${path}`, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body || {}),
})
// ── The public tier is anonymous, and hidden means absent ──────────────────
test('the public Team routes need no session', async () => {
liveSite()
patch(teams, 'listPublic', async () => ({ teams: [{ slug: 'a' }], total: 1, stale: false, lastSyncAt: null }))
await withApp('/api/v1/public', publicRouter, async (app) => {
const res = await get(app, '/api/v1/public/teams')
assert.equal(res.status, 200)
const body = await res.json()
assert.equal(body.total, 1)
assert.equal(body.stale, false, 'freshness travels with every public payload')
})
})
test('a hidden Team is a 404 to the public, indistinguishable from a missing one', async () => {
liveSite()
// The model returns null for hidden and for missing alike; the route must not
// tell them apart either, or "absent from every public surface" leaks the fact
// that the Team exists.
patch(teams, 'getPublic', async () => null)
patch(teams, 'rosterPublic', async () => null)
await withApp('/api/v1/public', publicRouter, async (app) => {
assert.equal((await get(app, '/api/v1/public/teams/admin')).status, 404)
assert.equal((await get(app, '/api/v1/public/teams/admin/members')).status, 404)
})
})
test('the index and the by-slug lookup agree about what exists', async () => {
// Found live: the index was keyed on a registered provider while the lookup
// goes by slug, so with the module uninstalled `/teams` was empty while
// `/teams/:slug/members` served a full roster — the index denying a Team that
// direct URLs answered for. The rows are core's and outlive the module that
// filled them; `configured: false` is how a client learns the projection is no
// longer maintained.
const rows = [
{ id: 1, slug: 'the-silver-hand', name: 'The Silver Hand', status: 'active', hidden: 0, member_count: 2 },
{ id: 2, slug: 'admin', name: 'Admin', status: 'active', hidden: 1, member_count: 1 },
]
patch(teamsDbModule, 'allActive', async () => rows)
patch(teamsDbModule, 'findBySlug', async (slug) => rows.find((r) => r.slug === slug))
patch(teamsDbModule, 'syncState', async () => null)
await withApp('/api/v1/public', publicRouter, async (app) => {
liveSite()
const list = await (await get(app, '/api/v1/public/teams')).json()
assert.equal(list.total, 1, 'the hidden Team is absent from the index')
assert.equal(list.configured, false, 'with no provider, the projection is reported unmaintained')
assert.equal(list.teams[0].slug, 'the-silver-hand')
// Everything the index lists resolves, and nothing it omits does.
assert.equal((await get(app, '/api/v1/public/teams/the-silver-hand')).status, 200)
assert.equal((await get(app, '/api/v1/public/teams/admin')).status, 404)
})
})
test('a public roster never carries a member key or a user id', async () => {
liveSite()
patch(teams, 'rosterPublic', async () => ({
members: [teams.publicMember({
display_name: 'Aldric', rank_label: 'Warlord', is_leader: 1, online: 1, user_id: 7, member_key: '0x1',
})],
stale: false,
lastSyncAt: null,
}))
await withApp('/api/v1/public', publicRouter, async (app) => {
const body = await (await get(app, '/api/v1/public/teams/x/members')).json()
const [member] = body.members
assert.equal(member.displayName, 'Aldric')
assert.equal(member.linked, true)
assert.equal('userId' in member, false, 'a site account id is not public')
assert.equal('memberKey' in member, false, 'a game-internal identifier is not public')
})
})
// ── The player tier is authenticated, and role-agnostic ───────────────────
test('the player Team routes reject an anonymous caller', async () => {
await withApp('/api/v1/player', playerRouter, async (app) => {
assert.equal((await get(app, '/api/v1/player/teams')).status, 401)
})
})
test('staff are a superset of players — an admin reaches their own Teams', async () => {
// The mistake this guards against has been made in this group once already: a
// requireRole('player') here 403s an admin off their own characters.
patch(teams, 'listForUser', async (userId) => ({ teams: [], forUser: userId, stale: false }))
for (const user of [player, moderator, admin]) {
signInAs(user)
// eslint-disable-next-line no-await-in-loop
await withApp('/api/v1/player', playerRouter, async (app) => {
const res = await get(app, '/api/v1/player/teams')
assert.equal(res.status, 200, `${user.role} must reach their own Teams`)
assert.equal((await res.json()).forUser, user.id, 'the handler is self-scoped to the session')
})
}
})
test('the player access route is scoped to the caller, not to a supplied id', async () => {
signInAs(player)
let seen = null
patch(teams, 'accessForUser', async (slug, userId) => { seen = { slug, userId }; return { slug, allowed: true } })
await withApp('/api/v1/player', playerRouter, async (app) => {
await get(app, '/api/v1/player/teams/the-silver-hand/access?userId=1')
assert.deepEqual(seen, { slug: 'the-silver-hand', userId: player.id }, 'the query string is not an identity')
})
})
// ── The admin tier is staff-wide, with one admin-only action ──────────────
test('a player is refused the admin Team surface', async () => {
signInAs(player)
await withApp('/api/v1/admin', adminRouter, async (app) => {
assert.equal((await get(app, '/api/v1/admin/teams')).status, 403)
})
})
test('a moderator reaches the review queue — that is who runs it', async () => {
signInAs(moderator)
patch(moderation, 'reviewQueue', async () => [{ id: 1, name: 'Admin', hidden_term: 'admin' }])
await withApp('/api/v1/admin', adminRouter, async (app) => {
const res = await get(app, '/api/v1/admin/teams/review')
assert.equal(res.status, 200)
assert.equal((await res.json()).teams[0].hidden_term, 'admin')
})
})
test('literal admin paths are not captured by /:id', async () => {
// Express is first-match-wins, and a /:id declared ahead of /review would turn
// the queue into a lookup for a Team whose id is "review" — a 400 from the
// validator, on a route that should have worked.
signInAs(admin)
patch(moderation, 'reviewQueue', async () => [])
patch(moderation, 'listRequests', async () => [])
patch(teamSync, 'reconcileNow', async () => ({ ok: true, created: 0 }))
await withApp('/api/v1/admin', adminRouter, async (app) => {
assert.equal((await get(app, '/api/v1/admin/teams/review')).status, 200)
assert.equal((await get(app, '/api/v1/admin/teams/requests')).status, 200)
assert.equal((await post(app, '/api/v1/admin/teams/resync')).status, 200)
})
})
test('a non-numeric team id is rejected by the validator', async () => {
signInAs(admin)
await withApp('/api/v1/admin', adminRouter, async (app) => {
assert.equal((await get(app, '/api/v1/admin/teams/not-a-number')).status, 400)
})
})
// ── The §2.9 gate, as the route sees it ───────────────────────────────────
test('a moderator un-hiding gets a pending result; an admin gets an applied one', async () => {
// The gate is decided from the caller's live role, so this asserts on the ACTOR
// the route handed the model — the thing that actually decides — rather than on
// two sessions swapped mid-test.
const calls = []
patch(moderation, 'requestOrApply', async ({ actor, action }) => {
calls.push({ role: actor.role, action })
return actor.role === 'admin' ? { ok: true, pending: false } : { ok: true, pending: true, requestId: 5 }
})
signInAs(moderator)
await withApp('/api/v1/admin', adminRouter, async (app) => {
const body = await (await post(app, '/api/v1/admin/teams/1/unhide', { reason: 'legit' })).json()
assert.equal(body.pending, true)
assert.equal(body.requestId, 5)
})
assert.deepEqual(calls, [{ role: 'moderator', action: 'unhide' }])
})
test('an admin un-hiding applies at once', async () => {
const calls = []
patch(moderation, 'requestOrApply', async ({ actor, action }) => {
calls.push({ role: actor.role, action })
return { ok: true, pending: false }
})
signInAs(admin)
await withApp('/api/v1/admin', adminRouter, async (app) => {
const body = await (await post(app, '/api/v1/admin/teams/1/unhide', {})).json()
assert.equal(body.pending, false)
})
assert.deepEqual(calls, [{ role: 'admin', action: 'unhide' }])
})
test('deciding a request is admin-only, inside a staff-wide group', async () => {
// The route is reachable by any staff member; the refusal comes from the model
// checking the role live, which is the design (§2.9) — a demoted moderator
// loses this the moment they are demoted, not when their token expires.
signInAs(moderator)
patch(moderation, 'decide', async ({ actor }) => (actor.role === 'admin'
? { ok: true, applied: true }
: { ok: false, status: 403, error: 'only an admin may decide a request' }))
await withApp('/api/v1/admin', adminRouter, async (app) => {
const res = await post(app, '/api/v1/admin/teams/requests/1/decide', { status: 'approved' })
assert.equal(res.status, 403)
})
})
test('an invalid decision status never reaches the model', async () => {
signInAs(admin)
let called = false
patch(moderation, 'decide', async () => { called = true; return { ok: true } })
await withApp('/api/v1/admin', adminRouter, async (app) => {
assert.equal((await post(app, '/api/v1/admin/teams/requests/1/decide', { status: 'maybe' })).status, 400)
})
assert.equal(called, false)
})
test('a leadership override requires both a member key and an effect', async () => {
signInAs(admin)
await withApp('/api/v1/admin', adminRouter, async (app) => {
assert.equal((await post(app, '/api/v1/admin/teams/1/leader-override', { effect: 'grant' })).status, 400)
assert.equal((await post(app, '/api/v1/admin/teams/1/leader-override', { memberKey: '0x1' })).status, 400)
assert.equal((await post(app, '/api/v1/admin/teams/1/leader-override', { memberKey: '0x1', effect: 'maybe' })).status, 400)
})
})
test('an empty display name is routed to the CLEAR action, not published as blank', async () => {
signInAs(admin)
let action = null
patch(moderation, 'requestOrApply', async (args) => { action = args.action; return { ok: true, pending: false } })
await withApp('/api/v1/admin', adminRouter, async (app) => {
await post(app, '/api/v1/admin/teams/1/display-name', { displayName: '' })
assert.equal(action, 'clear_display_name_override', 'an audit line must not read as publishing a blank name')
await post(app, '/api/v1/admin/teams/1/display-name', { displayName: 'The Old Guard' })
assert.equal(action, 'display_name_override')
})
})

View 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 teams 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)
})