1 Commits

Author SHA1 Message Date
92c6972344 test(login): stop the backoff-guard test racing its own one-second lock
A single recordFailure() locks for BASE_MS * 2 ** 0 — exactly one second — and
the test then does a real HTTP round trip against it. On CI that round trip took
1,456 ms and the guard correctly answered 200, failing the run for a reason that
has nothing to do with what the test is about.

Five failures lock for sixteen seconds. The subject is the guard's answer while
locked out, which is unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 08:04:56 -05:00
66 changed files with 36 additions and 12733 deletions

View File

@@ -28,36 +28,3 @@ TOTP_ISSUER=UOMysticmoon
DB_NAME=uomysticmoon
DB_USER=uomm
COOKIE_NAME=uomm_token
# ── The UO module — REQUIRED for this instance, not optional like the vars above.
#
# Core is game-agnostic (docs/website/MODULE_SYSTEM.md): every shard-facing
# surface this instance runs — the shard pages, the player's characters, vendors
# and houses, Admin → Shard, and the uo-link connection itself — lives in
# RunicGateway/Module-uo and reaches the deployment through this line. Without
# it, the same image is a perfectly working site with no game on it.
#
# It is declared here rather than left to Admin → Modules because a compose host
# should arrive at its own set at boot, and because this instance has a shard to
# be down for: the panel path would leave the site game-less between the image
# roll and someone clicking install.
#
# Bump the version deliberately, and read Module-uo's release notes when you do —
# the container resolves this at every start, so changing the version here is
# what upgrades the module. A version already unpacked is a no-op that makes no
# network call at all.
#
# This owns what is ON the volume, never whether the module RUNS: disabling it in
# Admin → Modules keeps it disabled across restarts even though its files return.
MODULES=uo@0.3.0=https://gitea.whitlocktech.com/RunicGateway/Module-uo/releases/download/v0.3.0/module-uo-0.3.0.json
# Module-uo reads these as the DEFAULTS for its uo-link connection, used only
# until Admin → Shard has been saved once — after that the encrypted DB config
# (`uo_link_config`) is authoritative and these are ignored. Left unset here on
# purpose: an instance that has already saved Admin → Shard keeps that config
# across the extraction (the module's schema fragment is CREATE TABLE IF NOT
# EXISTS, so the existing row is untouched), and setting them would suggest they
# still decide something. Module-uo's README documents them.
# UOLINK_BASE_URL=
# UOLINK_WS_URL=
# UOLINK_PROTOCOL=

View File

@@ -18,9 +18,6 @@ import Newsletter from './routes/public/Newsletter.jsx'
import NewsletterIssue from './routes/public/NewsletterIssue.jsx'
import About from './routes/public/About.jsx'
import Status from './routes/public/Status.jsx'
import Teams from './routes/public/Teams.jsx'
import Team from './routes/public/Team.jsx'
import TeamRoster from './routes/public/TeamRoster.jsx'
import Wiki from './routes/wiki/Wiki.jsx'
import WikiArticle from './routes/wiki/WikiArticle.jsx'
import CmsPage from './routes/public/CmsPage.jsx'
@@ -45,7 +42,6 @@ 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'
@@ -60,7 +56,6 @@ import AcceptInvite from './routes/player/AcceptInvite.jsx'
import PlayerPortalLayout, { PlayerIndex } from './routes/player/PlayerPortalLayout.jsx'
import PlayerAccount from './routes/player/PlayerAccount.jsx'
import PlayerAppeals from './routes/player/PlayerAppeals.jsx'
import PlayerTeams from './routes/player/PlayerTeams.jsx'
export default function App() {
return (
@@ -97,13 +92,6 @@ export default function App() {
<Route path="/site/status" element={<Status />} />
<Route path="/wiki" element={<Wiki />} />
<Route path="/wiki/:slug" element={<WikiArticle />} />
{/* Teams (TEAMS.md §3.1). Core routes, not module ones: a Team is a
core platform entity that a module merely populates, so these
render on bare core too. `/teams` is declared before `/:slug`
below for the same reason every named route is. */}
<Route path="/teams" element={<Teams />} />
<Route path="/teams/:slug" element={<Team />} />
<Route path="/teams/:slug/roster" element={<TeamRoster />} />
{/* Installed modules' public pages, namespaced `/<id>/…` — the
registry prefixes the segment, so a module cannot spell its way
out of it (docs/website/MODULE_API.md §3.3). Declared before the
@@ -186,10 +174,6 @@ 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
@@ -227,11 +211,6 @@ export default function App() {
<Route path="/player" element={<PlayerIndex />} />
<Route path="/account" element={<PlayerAccount />} />
<Route path="/account/appeals" element={<PlayerAppeals />} />
{/* Core's own page under the /player prefix, unlike the module
pages below it. Open to any authenticated account, not role
'player': staff are a superset of players and a moderator is in
guilds too — RequirePlayer above already draws that line. */}
<Route path="/player/teams" element={<PlayerTeams />} />
{/* Installed modules' player-portal pages, at /player/<id>/…. This
group's own routes are absolute (its layout route has no path),
so the prefix is written here rather than inherited — the one

View File

@@ -133,29 +133,6 @@ export const api = {
return req(`/public/wiki${withQs(s)}`)
},
wikiCategories: () => req('/public/wiki/categories'),
// ----- Teams (TEAMS.md §2.11, §3.1) -----
//
// Public reads, but three of them behave differently for a signed-in caller and
// the session rides along on the cookie the shared `req` already sends: the
// roster may widen with the module's audience projection, and the activity feed
// adds members-only items. None of them REQUIRES a session.
teams: (opts = {}) => {
const qs = new URLSearchParams()
if (opts.limit != null) qs.set('limit', String(opts.limit))
if (opts.offset != null) qs.set('offset', String(opts.offset))
return req(`/public/teams${withQs(qs.toString())}`)
},
team: (slug) => req(`/public/teams/${encodeURIComponent(slug)}`),
teamRoster: (slug) => req(`/public/teams/${encodeURIComponent(slug)}/members`),
teamActivity: (slug, opts = {}) => {
const qs = new URLSearchParams()
if (opts.limit != null) qs.set('limit', String(opts.limit))
if (opts.offset != null) qs.set('offset', String(opts.offset))
return req(`/public/teams/${encodeURIComponent(slug)}/activity${withQs(qs.toString())}`)
},
myTeams: () => req('/player/teams'),
myTeamAccess: (slug) => req(`/player/teams/${encodeURIComponent(slug)}/access`),
wikiTags: () => req('/public/wiki/tags'),
wikiPage: (slug) => req(`/public/wiki/${slug}`),
// CMS pages (block-based). Published-only for the public; a draft-preview link
@@ -270,29 +247,6 @@ 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 = {}) => {

View File

@@ -1,36 +1,12 @@
import SiteHeader from './SiteHeader.jsx'
import SiteFooter from './SiteFooter.jsx'
import { shellClass } from '../lib/pageShell.js'
// Standard page chrome for the public site + wiki.
//
// ── `shell` — added in MODULE_API_VERSION 1.5.0 ────────────────────────────
//
// This component supplies the chrome and NOT the body: every core public page
// wraps its own content in `<div className="shell-… page-body">`, which is what
// centres it in a max-width column, gives it its top and bottom padding, and —
// through `page-body { flex: 1 }` — pushes the footer to the bottom of the
// viewport. Nine of nine core pages do it, so the omission has never shown.
//
// A module page cannot: it is handed `PublicLayout` through the UI kit
// (MODULE_API.md §3.4) and has no way to learn about two class names that appear
// in no contract. The Integration Kit's acceptance run built a module exactly as
// the kit teaches and it rendered full-bleed at x=0 with the footer riding up
// under the content — the precise failure §3.4 says the kit exists to prevent
// ("a module page that does not look like the site it is installed in").
//
// So the wrapper moves behind the component a module already has. `shell` is
// OPT-IN and omitting it is exactly today's behaviour, which is why core's own
// nine pages are untouched by this change — they keep their own wrapper, and a
// page wanting an unusual body still writes its own. The width mapping and its
// fallback are in lib/pageShell.js, where the DOM-less test runner can reach them.
export default function PublicLayout({ section = 'website', header = true, shell, children }) {
const bodyClass = shellClass(shell)
export default function PublicLayout({ section = 'website', header = true, children }) {
return (
<div className="page">
{header && <SiteHeader section={section} />}
{bodyClass ? <div className={bodyClass}>{children}</div> : children}
{children}
<SiteFooter />
</div>
)

View File

@@ -28,11 +28,6 @@ import { useFeatureGate } from '../modules/features.jsx'
export const NAV = [
{ label: 'Home', to: '/', end: true },
{ label: 'News', to: '/site/news' },
// The first CORE row to carry a `feature` since the shard rows left in slice 3
// (TEAMS.md §3.5). It is answered by core's own provider (main.jsx) and gates
// on whether this deployment has Teams at all, not on who is looking — Team
// pages are public. Fails open, so an unknown answer shows the link.
{ label: 'Teams', to: '/teams', feature: 'teams' },
{ label: 'Screenshots', to: '/site/screenshots' },
{ label: 'Five on Friday', to: '/site/five-on-friday' },
{ label: 'Newsletter', to: '/site/newsletter' },

View File

@@ -1,26 +0,0 @@
// The page-body shell core's public pages sit in, as plain JS.
//
// Extracted from PublicLayout.jsx for the reason lib/adminNav.js was: the client
// test runner has no DOM and cannot import a .jsx file at all
// (client/test/moduleRegistry.test.js says the same about modules/shared.js), so
// anything with a rule worth asserting has to live outside the component.
//
// The rule worth asserting here is the fallback. `shell` is part of the module
// contract as of MODULE_API_VERSION 1.5.0 (MODULE_API.md §3.4), which means the
// value can come from a module core has never seen, written against a version of
// this list that is older or newer than the one running. An unknown width must
// therefore still produce a wrapper: a module page at the wrong width looks like
// the site, and a page with no wrapper does not — it renders full-bleed with the
// footer riding up under it, which is the defect the prop exists to fix.
const SHELLS = { narrow: 'shell-narrow', mid: 'shell-mid', wide: 'shell-wide' }
export const SHELL_WIDTHS = Object.keys(SHELLS)
// Returns the className for a page body, or null when no shell was asked for —
// null is "render children bare", which is every core page written before 1.5.0
// and stays the default forever.
export function shellClass(shell) {
if (!shell) return null
return `${SHELLS[shell] || SHELLS.narrow} page-body`
}

View File

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

View File

@@ -1,151 +0,0 @@
// What the public Team pages SAY, separated from how they render
// (docs/website/TEAMS.md §3.2, §3.3, §4.3).
//
// Plain JS with tests, following lib/teamAdmin.js. It is worth splitting here for
// the same reason it was there: these pages have to state differences that look
// like bugs unless they are worded deliberately. A roster header reading
// "37 members · 21 linked" is information; the same numbers with no explanation
// is a support ticket. And an empty roster has three unrelated causes — a Team
// with nobody in it, an audience rung that shows nobody, and a module that could
// not be asked — which is logic, not markup.
/** How the roster describes a row's relationship to a site account (§3.2). */
export const LINK_STATE = { linked: 'linked', unlinked: 'unlinked' }
export function linkStateOf(member) {
return member && member.linked ? LINK_STATE.linked : LINK_STATE.unlinked
}
/**
* The roster header line.
*
* The gap between the two numbers is the surfaced divergence Part 2 asks for: it
* must read as information rather than as a discrepancy, which is why the line
* says what each number IS instead of showing them side by side and hoping.
*
* `guests` is phase 4's forum grants and is omitted while there are none, so the
* line does not carry a permanent zero for a feature that has not shipped.
*/
export function rosterSummary({ members = 0, linked = 0, guests = 0 } = {}) {
const parts = [`${members} ${members === 1 ? 'member' : 'members'}`, `${linked} linked`]
if (guests > 0) parts.push(`${guests} forum ${guests === 1 ? 'guest' : 'guests'}`)
return parts.join(' · ')
}
const MINUTE = 60_000
const HOUR = 60 * MINUTE
const DAY = 24 * HOUR
/** "just now" / "14 minutes ago" / "3 hours ago" / "2 days ago". */
export function relativeTime(when, now = Date.now()) {
if (!when) return null
const ms = now - new Date(when).getTime()
if (!Number.isFinite(ms)) return null
if (ms < MINUTE) return 'just now'
if (ms < HOUR) {
const n = Math.floor(ms / MINUTE)
return `${n} ${n === 1 ? 'minute' : 'minutes'} ago`
}
if (ms < DAY) {
const n = Math.floor(ms / HOUR)
return `${n} ${n === 1 ? 'hour' : 'hours'} ago`
}
const n = Math.floor(ms / DAY)
return `${n} ${n === 1 ? 'day' : 'days'} ago`
}
/**
* How a public page describes the projection's freshness (§2.4).
*
* Distinct from `teamAdmin.freshnessOf`, which is worded for an operator
* debugging a sync. A visitor needs one sentence about whether what they are
* looking at is current, and specifically must never be shown an unconfirmed
* empty projection as though it were a confirmed empty shard.
*/
export function freshnessNote(sync = {}, now = Date.now()) {
// Nothing supplies Teams here, so there is nothing to be stale ABOUT. A
// deployment with no game module is not a broken one.
if (!sync.configured) return null
if (!sync.lastSyncAt) return { tone: 'warn', text: 'Not yet confirmed against the game.' }
const ago = relativeTime(sync.lastSyncAt, now)
if (sync.stale) return { tone: 'warn', text: `Last confirmed ${ago} — the game may have moved on.` }
return { tone: 'idle', text: `Last confirmed ${ago}.` }
}
/**
* Why a roster is empty, in the viewer's terms.
*
* Returns null when it is not empty. The three causes are genuinely different and
* reporting the wrong one is the failure this function exists to prevent: telling
* someone a guild has no members when in fact the module could not be asked is a
* statement about the game that happens to be false.
*/
export function emptyRosterReason(roster = {}) {
const members = roster.members || []
if (members.length) return null
if (roster.projectionUnavailable) {
return 'The roster cannot be shown right now — the game module could not be reached.'
}
if (roster.configured && !roster.lastSyncAt) {
return 'This roster has not been confirmed against the game yet.'
}
return 'Nobody is in this Team.'
}
/**
* Group feed items into days, newest first, preserving order within a day (§4.3).
*
* Keyed by local calendar date rather than by a UTC slice: "yesterday" is a
* property of where the reader is sitting, and a shard's evening raid landing at
* 00:30 UTC belongs on the day the players experienced it.
*/
export function groupByDay(items = [], locale = undefined) {
const days = []
const byKey = new Map()
for (const item of items) {
const date = new Date(item.occurredAt)
if (Number.isNaN(date.getTime())) continue
const key = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`
if (!byKey.has(key)) {
const day = {
key,
label: date.toLocaleDateString(locale, { year: 'numeric', month: 'long', day: 'numeric' }),
items: [],
}
byKey.set(key, day)
days.push(day)
}
byKey.get(key).items.push(item)
}
return days
}
/**
* What to say under a feed that has been filtered.
*
* Only when there is something to say: a caller who saw everything is told
* nothing, and an anonymous caller is invited to sign in rather than simply
* informed that items exist which they cannot have.
*/
export function activityScopeNote(feed = {}, signedIn = false) {
if (feed.scope !== 'public') return null
return signedIn
? 'Some entries are visible to members of this Team only.'
: 'Sign in as a member of this Team to see its members-only entries.'
}
/** Sort for the index: most members first, then alphabetically. */
export function sortTeams(teams = []) {
return [...teams].sort(
(a, b) => (b.memberCount || 0) - (a.memberCount || 0) || String(a.name).localeCompare(String(b.name)),
)
}
/** The index's search, over the two things a visitor knows a Team by. */
export function filterTeams(teams = [], query = '') {
const q = query.trim().toLowerCase()
if (!q) return teams
return teams.filter(
(t) => String(t.name || '').toLowerCase().includes(q) || String(t.abbr || '').toLowerCase().includes(q),
)
}

View File

@@ -3,8 +3,7 @@ import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App.jsx'
import { publishSharedDependencies } from './modules/shared.js'
import { declareSlot, registerFeatureProvider } from './modules/registry.js'
import { useCoreFlags } from './modules/coreFeatures.js'
import { declareSlot } from './modules/registry.js'
import './styles/theme.css'
// Publish window.__rg BEFORE rendering and before any module chunk evaluates.
@@ -19,12 +18,8 @@ publishSharedDependencies()
// and namespace `uo`, so that the seam was exercised by real content from the
// day it was built. That prediction paid out exactly as written: the extraction
// deleted the registration and the hook it named, and SiteHeader was not touched.
//
// Teams put a core row back on the seam. `feature: 'teams'` on the three Team nav
// rows resolves against owner id `core` (featureGate.js: a row with no `moduleId`
// belongs to core), and this is the provider that answers it — hiding the rows on
// a deployment that has no Teams at all, and failing open everywhere else.
registerFeatureProvider('core', 'core', useCoreFlags)
// There is nothing for core to register now — no core nav row carries a
// `feature` — and the filter is a correct no-op until a module supplies one.
// ── Extension slots (MODULE_API.md §3.7) ───────────────────────────────────
//
@@ -55,18 +50,6 @@ declareSlot('admin.users.detail')
// over `onDone`. With the slot unfilled the invitee goes straight to the portal,
// which is what core's own code did whenever the flag was off.
declareSlot('player.invite.accepted')
// The two Team slots (TEAMS.md §3.4, MODULE_API 1.6.0). Both named for a place:
// `team.overview` is the spot under the counts on a Team page, not "where the
// game puts guild stats", and `team.member.row` is the trailing cell of a roster
// row. Core renders the whole Team experience with both unfilled — the pages are
// core's, and a module adds to them rather than supplying them.
//
// `team.overview` is where a live "online now" strip belongs: core's online count
// is the durable floor refreshed at the reconcile interval (§3.3), and a module
// that already holds a live presence feed can render the current number here
// without core acquiring an SSE stack to do it.
declareSlot('team.overview')
declareSlot('team.member.row')
// Core filled the first two itself until slice 3, with the components that were
// inline in SiteFooter.jsx and UserDetail.jsx. Both are gone: the module fills

View File

@@ -1,56 +0,0 @@
import { useEffect, useState } from 'react'
// Core's own feature provider (TEAMS.md §3.5, MODULE_API.md §3.3).
//
// Core registered one here until the module cutover, under owner id `core` and
// namespace `uo`, and it left with the shard rows. This brings the seam back with
// content that is genuinely core's: `teams` gates the Teams nav rows, and Teams
// are a core platform entity that a module merely populates.
//
// **What the flag actually answers is "does this deployment have Teams at all".**
// Not "may this viewer see them" — Team pages are public (§0.7) and the server
// gates them. On bare core, with no module supplying a Team provider and no rows
// left behind by one, `/teams` is a permanently empty page and a link to it is
// worse than no link. That is the whole job.
//
// It fails OPEN, like every other answer in this seam: while the request is in
// flight, and on any error, the hook returns `null`, which `buildFeatureGate`
// reads as "we do not know yet" and SHOWS the row. The page itself is the gate.
// The one thing a UI mistake must never do here is hide a surface from someone
// entitled to it — and a Teams link that leads somewhere empty is a far cheaper
// mistake than a Team page nobody can find.
// `limit=1` because only `enabled` is wanted. The endpoint answers it whatever
// the page size, and asking for the default fifty would pull a roster's worth of
// counts into a nav decision.
const TEAMS_URL = '/api/v1/public/teams?limit=1'
/**
* The hook core registers. Returns a Set-like of visible flags, or `null` while
* the answer is unknown.
*
* Fetched once per mount rather than subscribed: whether a deployment has Teams
* changes when a module is installed, which is a restart, not a session event.
*/
export function useCoreFlags() {
const [flags, setFlags] = useState(null)
useEffect(() => {
let active = true
fetch(TEAMS_URL, { credentials: 'same-origin' })
.then((res) => (res.ok ? res.json() : null))
.then((body) => {
if (!active) return
// A body that does not carry `enabled` is an older server or a shape
// change, and both are "unknown" rather than "no".
if (!body || typeof body.enabled !== 'boolean') return
setFlags(new Set(body.enabled ? ['teams'] : []))
})
.catch(() => {}) // stays null: unknown shows the row
return () => { active = false }
}, [])
return flags
}
export default useCoreFlags

View File

@@ -40,8 +40,7 @@ import { useSite } from '../contexts/SiteContext.jsx'
import { request, ApiError, BASE } from '../api/client.js'
// The UI kit is CURATED AND CLOSED (§3.4), not a re-export of components/. These
// eight exports — five table rows in §3.4, since `PageState` contributes three —
// are what the smallest UO page already needs beyond React and the router:
// seven are what the smallest UO page already needs beyond React and the router:
// without them a module either reaches into core's tree — violating the
// zero-import rule the whole boundary rests on — or ships its own copies, which
// means a module page that does not look like the site it is installed in, and
@@ -51,12 +50,11 @@ import { request, ApiError, BASE } from '../api/client.js'
// is a MAJOR one. That is a real constraint on core's own refactoring and it is
// the price of the boundary being worth anything.
//
// `AdminPage` was in an early draft of §3.4's table and is deliberately absent:
// core has no such component — admin views are plain markup inside AdminLayout —
// and inventing one to satisfy a table would be a core change with no consumer
// until Phase 3. The contract was amended rather than the code padded (it no
// longer lists it), and adding it later costs a minor bump, which is exactly the
// case the versioning is for.
// `AdminPage` appears in §3.4's table and is deliberately absent: core has no
// such component — admin views are plain markup inside AdminLayout — and
// inventing one to satisfy a table would be a core change with no consumer until
// Phase 3. The contract is amended rather than the code padded, and adding it
// later costs a minor bump, which is exactly the case the versioning is for.
const ui = {
PublicLayout,
PageHeader,

View File

@@ -11,25 +11,6 @@
// 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
// component's props is major" does not bite — nothing already written changes
// meaning. It exists because the kit's acceptance run proved a module cannot
// discover the wrapper: the class names are theme.css's and appear in no
// contract, so a module page rendered outside the site's column while doing
// everything the kit said (docs/modules/kit-acceptance.md).
// 1.4.0 — a rule, not a member: §2.7 forbids a module opening a connection to a
// game server from the website process (it talks to a sidecar, which owns the
// durable copy). Nothing on window.__rg changed and nothing on the server's ctx
// changed either; this half bumps because the two halves state ONE version.
// 1.3.0 — three additions, all from Phase 3 slice 3 needing them: a nav item may
// carry an `icon` component (§3.3), core declares a third slot
// `player.invite.accepted` (§3.7), and `window.__rg.api` gained `BASE`, which
@@ -45,4 +26,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.6.0'
export const MODULE_API_VERSION = '1.3.0'

View File

@@ -76,12 +76,6 @@ 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

@@ -1,301 +0,0 @@
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

@@ -35,7 +35,6 @@ function Icon({ children, size = 16 }) {
}
const IconGear = () => <Icon><circle cx="12" cy="12" r="3" /><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M19.1 4.9L17 7M7 17l-2.1 2.1" /></Icon>
const IconShield = () => <Icon><path d="M12 3l7 3v5c0 5-3.5 8-7 10-3.5-2-7-5-7-10V6z" /><path d="M9 12l2 2 4-4" /></Icon>
const IconTeams = () => <Icon><circle cx="9" cy="8" r="3" /><path d="M3 20v-1a5 5 0 015-5h2a5 5 0 015 5v1" /><path d="M16 5.5a3 3 0 010 5.8M18 20v-1a5 5 0 00-2-4" /></Icon>
// Exported because Admin -> Navigation edits this list. It stays declared here;
// the editor may only relabel, reorder and hide what it finds (§7). No CORE row
@@ -47,12 +46,6 @@ const IconTeams = () => <Icon><circle cx="9" cy="8" r="3" /><path d="M3 20v-1a5
// UO module registers it again at `/player/uo/characters`, in this position,
// with `order: 0`.
export const NAV = [
// Gated on the same core `teams` flag as the public header row, for the same
// reason: on a deployment with no Teams this leads to a permanently empty page
// (TEAMS.md §3.5). It is NOT gated on role — staff are a superset of players
// and a moderator is in guilds too, which is the mistake this portal has
// already made once.
{ to: '/player/teams', label: 'My Teams', icon: IconTeams, feature: 'teams' },
{ to: '/account/appeals', label: 'Appeals', icon: IconShield },
{ to: '/account', label: 'Account', end: true, icon: IconGear },
]
@@ -63,7 +56,6 @@ export const NAV = [
const TITLES = {
'/account': 'Account',
'/account/appeals': 'Appeals',
'/player/teams': 'My Teams',
}
function moduleTitle(baseNav, pathname) {

View File

@@ -1,67 +0,0 @@
import { Link } from 'react-router-dom'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { api } from '../../api/client.js'
import { rosterSummary } from '../../lib/teams.js'
// "My Teams" (TEAMS.md §3.1) — the caller's Teams and what each one grants them.
//
// **Membership and a forum grant are separate authority paths**, so every row
// carries the reason it is here. `both` is a real state and is kept: someone who
// was granted forum access and has since joined sees membership as the current
// reason without the grant vanishing from the record.
//
// A Team HIDDEN from public surfaces still appears here. Suppression is a
// public-surface rule and a member is not a member of the public — but the page
// says so, because a Team listed here that 404s when clicked is otherwise
// indistinguishable from a broken link.
const REASON = {
membership: { label: 'Member', detail: 'You are in this Team in-game.' },
grant: { label: 'Forum guest', detail: 'You were granted access to this Team’s forum.' },
both: { label: 'Member', detail: 'You are a member, and hold a forum grant as well.' },
}
export default function PlayerTeams() {
const { loading, error, data } = useAsync(() => api.myTeams())
const teams = data?.teams || []
return (
<>
{loading && <Loading />}
{error && <ErrorState message="Could not load your Teams right now." />}
{!loading && !error && teams.length === 0 && (
<p style={{ color: 'var(--muted)' }}>
You are not in any Team. Teams come from the game — join a guild in-game and it will
appear here after the next sync.
</p>
)}
<div style={{ display: 'grid', gap: 12 }}>
{teams.map((team) => {
const reason = REASON[team.reason] || REASON.membership
return (
<div key={team.slug} className="panel" style={{ padding: '16px 20px' }}>
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<Link to={`/teams/${team.slug}`} className="display" style={{ color: 'var(--head)', fontSize: '1.05rem' }}>
{team.name}
</Link>
<span style={{ color: 'var(--accent)', fontSize: '0.8rem' }}>
{reason.label}
{team.isLeader && ' · Leader'}
</span>
</div>
<p style={{ color: 'var(--muted)', fontSize: '0.88rem', margin: '6px 0 0' }}>
{reason.detail}
</p>
<p style={{ color: 'var(--muted)', fontSize: '0.85rem', margin: '4px 0 0' }}>
{rosterSummary({ members: team.memberCount, linked: team.linkedCount })}
</p>
</div>
)
})}
</div>
</>
)
}

View File

@@ -1,126 +0,0 @@
import { Link, useParams } from 'react-router-dom'
import PublicLayout from '../../components/PublicLayout.jsx'
import PageHeader from '../../components/PageHeader.jsx'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import Slot from '../../modules/Slot.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { api } from '../../api/client.js'
import { activityScopeNote, freshnessNote, groupByDay, rosterSummary } from '../../lib/teams.js'
// A Team's overview page (TEAMS.md §3.1, §3.3, §4.3).
//
// Three things arrive separately and none of them may take the page down: the
// Team itself, its activity feed, and whatever a module renders in the
// `team.overview` slot. The Team is the only one this page cannot render without.
const ACTIVITY_PAGE = 25
export default function Team() {
const { slug } = useParams()
const { user } = useAuth()
const { loading, error, data } = useAsync(() => api.team(slug), [slug])
// Deliberately not awaited alongside the Team: a feed that is slow or failing
// must not hold back the counts and the leaders, which are the page's point.
const feed = useAsync(() => api.teamActivity(slug, { limit: ACTIVITY_PAGE }), [slug])
const note = data ? freshnessNote(data) : null
const days = groupByDay(feed.data?.items || [])
const scopeNote = feed.data ? activityScopeNote(feed.data, Boolean(user)) : null
return (
<PublicLayout section="website">
<div className="shell-mid page-body">
{loading && <Loading />}
{error && <ErrorState message="Could not load this Team right now." />}
{!loading && !error && !data && <ErrorState message="No such Team." />}
{!loading && !error && data && (
<>
<PageHeader
eyebrow={data.abbr ? `[${data.abbr}]` : 'Team'}
title={data.name}
/>
{/* An archived Team still resolves, read-only, and says what became of
it (§2.2) — an old bookmark or Discord link must land somewhere
that explains itself rather than 404ing. */}
{data.status === 'archived' && (
<p className="panel" style={{ padding: '14px 18px', marginBottom: 18, color: 'var(--muted)' }}>
This Team is no longer active.
{data.successor && (
<>
{' '}It is now{' '}
<Link to={`/teams/${data.successor.slug}`}>{data.successor.name}</Link>.
</>
)}
</p>
)}
<p style={{ color: 'var(--muted)', marginBottom: 6 }}>
{rosterSummary({ members: data.memberCount, linked: data.linkedCount })}
{data.onlineCount > 0 && ` · ${data.onlineCount} online`}
</p>
{note && (
<p style={{ color: note.tone === 'warn' ? 'var(--mode-maint)' : 'var(--muted)', fontSize: '0.85rem', marginBottom: 18 }}>
{note.text}
</p>
)}
<p style={{ marginBottom: 26 }}>
<Link to={`/teams/${slug}/roster`}>View the full roster →</Link>
</p>
{/* The module's spot, under the counts. Core's online number above is
the durable floor refreshed at the reconcile interval; a module
holding a live presence feed renders the current one here, without
core acquiring an SSE stack for it (§3.3). Unfilled on bare core,
and a failure inside it is contained to this section. */}
<Slot
name="team.overview"
teamId={data.id}
externalId={data.externalId}
moduleId={data.moduleId}
/>
<section style={{ marginTop: 30 }}>
<h2 className="display" style={{ fontSize: '1.2rem', color: 'var(--head)', marginBottom: 12 }}>
Recent activity
</h2>
{feed.loading && <Loading />}
{/* A failed feed is a missing SECTION, never a failed page. */}
{feed.error && (
<p style={{ color: 'var(--muted)' }}>The activity feed could not be loaded.</p>
)}
{!feed.loading && !feed.error && days.length === 0 && (
<p style={{ color: 'var(--muted)' }}>Nothing has happened here yet.</p>
)}
{days.map((day) => (
<div key={day.key} style={{ marginBottom: 18 }}>
<h3 style={{ fontSize: '0.82rem', color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 6 }}>
{day.label}
</h3>
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'grid', gap: 6 }}>
{day.items.map((item) => (
<li key={item.id} style={{ color: 'var(--text)', fontSize: '0.95rem' }}>
{item.summary}
</li>
))}
</ul>
</div>
))}
{scopeNote && (
<p style={{ color: 'var(--muted)', fontSize: '0.85rem', marginTop: 12 }}>{scopeNote}</p>
)}
</section>
</>
)}
</div>
</PublicLayout>
)
}

View File

@@ -1,120 +0,0 @@
import { Link, useParams } from 'react-router-dom'
import PublicLayout from '../../components/PublicLayout.jsx'
import PageHeader from '../../components/PageHeader.jsx'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import Slot from '../../modules/Slot.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { api } from '../../api/client.js'
import { emptyRosterReason, freshnessNote, rosterSummary } from '../../lib/teams.js'
// The full roster (TEAMS.md §3.2). `shell="wide"` per §3.1 — this is the one
// Team page with a table wide enough to want the room.
//
// **The link state is a first-class column, not an absence.** A roster where 37
// members show but only 21 carry a profile link looks broken until the page says
// what the difference is; §3.2 exists because that gap is information (a
// character with no site account behind it) and has to read as such.
export default function TeamRoster() {
const { slug } = useParams()
const team = useAsync(() => api.team(slug), [slug])
const roster = useAsync(() => api.teamRoster(slug), [slug])
const members = roster.data?.members || []
const linked = members.filter((m) => m.linked).length
const note = roster.data ? freshnessNote(roster.data) : null
const emptyReason = roster.data ? emptyRosterReason(roster.data) : null
return (
<PublicLayout section="website" shell="wide">
<PageHeader
eyebrow={team.data ? team.data.name : 'Team'}
title="Roster"
/>
<p style={{ marginBottom: 18 }}>
<Link to={`/teams/${slug}`}>← Back to the Team</Link>
</p>
{(team.loading || roster.loading) && <Loading />}
{roster.error && <ErrorState message="Could not load this roster right now." />}
{!roster.loading && !roster.error && roster.data && (
<>
<p style={{ color: 'var(--muted)', marginBottom: 6 }}>
{rosterSummary({ members: members.length, linked })}
</p>
{note && (
<p style={{ color: note.tone === 'warn' ? 'var(--mode-maint)' : 'var(--muted)', fontSize: '0.85rem', marginBottom: 18 }}>
{note.text}
</p>
)}
{emptyReason && <p style={{ color: 'var(--muted)' }}>{emptyReason}</p>}
{members.length > 0 && (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ textAlign: 'left', color: 'var(--muted)', fontSize: '0.8rem', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
<th style={{ padding: '8px 10px' }}>Name</th>
<th style={{ padding: '8px 10px' }}>Rank</th>
<th style={{ padding: '8px 10px' }}>Status</th>
<th style={{ padding: '8px 10px' }} />
</tr>
</thead>
<tbody>
{/* Keyed by position, unavoidably: the row's stable identifier
is its member key and that is exactly what is not published
(§3.2). Two characters may share a display name. */}
{members.map((member, i) => (
// eslint-disable-next-line react/no-array-index-key
<tr key={`${member.displayName}-${i}`} style={{ borderTop: '1px solid var(--line)' }}>
<td style={{ padding: '10px', color: member.linked ? 'var(--head)' : 'var(--muted)' }}>
{member.displayName}
{member.isLeader && (
<span style={{ color: 'var(--accent)', marginLeft: 8, fontSize: '0.78rem' }}>Leader</span>
)}
{/* Muted text alone would read as a rendering glitch; the
chip is what makes the gap in the header line legible. */}
{!member.linked && (
<span style={{ color: 'var(--muted)', marginLeft: 8, fontSize: '0.75rem', border: '1px solid var(--line)', borderRadius: 999, padding: '1px 8px' }}>
not linked
</span>
)}
</td>
<td style={{ padding: '10px', color: 'var(--muted)' }}>{member.rankLabel || '—'}</td>
<td style={{ padding: '10px', color: 'var(--muted)' }}>
{member.online ? 'Online' : 'Offline'}
</td>
{/* The module's trailing cell. Nothing on bare core.
§3.4 declares this slot's props as `{ memberKey, userId,
displayName }`, and two of those cannot be supplied:
§3.2 withholds the member key and the user id from every
public roster response, so they are not in the payload
this component was rendered from. Publishing them to
reach the slot would put a game-internal identifier and
a site account id on a public page for every visitor,
module installed or not — the doc's two sections
contradict each other and §3.2 is the one that is a
security rule. The slot gets what core can honestly
give it. */}
<td style={{ padding: '10px', textAlign: 'right' }}>
<Slot
name="team.member.row"
displayName={member.displayName}
isLeader={member.isLeader}
linked={member.linked}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)}
</PublicLayout>
)
}

View File

@@ -1,107 +0,0 @@
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import PublicLayout from '../../components/PublicLayout.jsx'
import PageHeader from '../../components/PageHeader.jsx'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { api } from '../../api/client.js'
import { filterTeams, freshnessNote, rosterSummary, sortTeams } from '../../lib/teams.js'
// The Team index (TEAMS.md §3.1). Core's own page: Teams are a core platform
// entity and a module only populates them, so this renders on bare core too —
// it just has nothing to list, which the empty state says plainly rather than
// implying something is broken.
export default function Teams() {
const { loading, error, data } = useAsync(() => api.teams({ limit: 200 }))
const [query, setQuery] = useState('')
const teams = useMemo(() => filterTeams(sortTeams(data?.teams || []), query), [data, query])
const note = data ? freshnessNote(data) : null
const total = data?.total || 0
return (
<PublicLayout section="website">
<div className="shell-mid page-body">
<PageHeader eyebrow="Community" title="Teams" />
{loading && <Loading />}
{error && <ErrorState message="Could not load Teams right now." />}
{!loading && !error && (
<>
{note && (
<p style={{ color: note.tone === 'warn' ? 'var(--mode-maint)' : 'var(--muted)', fontSize: '0.9rem', marginBottom: 18 }}>
{note.text}
</p>
)}
{total > 0 && (
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search by name or abbreviation"
aria-label="Search Teams"
style={{
width: '100%',
padding: '10px 14px',
marginBottom: 20,
borderRadius: 8,
border: '1px solid var(--line)',
background: 'rgba(0,0,0,0.25)',
color: 'var(--head)',
fontFamily: 'var(--sans)',
}}
/>
)}
{total === 0 && (
<p style={{ color: 'var(--muted)' }}>
{data.configured
? 'No Teams yet.'
: 'This site has no Teams — no installed module supplies them.'}
</p>
)}
{total > 0 && teams.length === 0 && (
<p style={{ color: 'var(--muted)' }}>No Team matches “{query}”.</p>
)}
<div style={{ display: 'grid', gap: 12 }}>
{teams.map((team) => (
<Link
key={team.slug}
to={`/teams/${team.slug}`}
className="panel"
style={{
display: 'flex',
alignItems: 'baseline',
justifyContent: 'space-between',
gap: 16,
padding: '16px 20px',
textDecoration: 'none',
flexWrap: 'wrap',
}}
>
<span>
<strong className="display" style={{ color: 'var(--head)', fontSize: '1.1rem' }}>
{team.name}
</strong>
{team.abbr && (
<span style={{ color: 'var(--muted)', marginLeft: 8, fontSize: '0.9rem' }}>[{team.abbr}]</span>
)}
</span>
<span style={{ color: 'var(--muted)', fontSize: '0.88rem' }}>
{rosterSummary({ members: team.memberCount, linked: team.linkedCount })}
{team.onlineCount > 0 && ` · ${team.onlineCount} online`}
</span>
</Link>
))}
</div>
</>
)}
</div>
</PublicLayout>
)
}

View File

@@ -1,59 +0,0 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { shellClass, SHELL_WIDTHS } from '../src/lib/pageShell.js'
// `PublicLayout`'s `shell` prop (MODULE_API.md §3.4, MODULE_API_VERSION 1.5.0).
// The component itself is .jsx and unreachable from this runner — there is no DOM
// here — so the rule lives in lib/pageShell.js and is asserted here, and the
// rendering is proved in a browser (MODULE_API.md §7.7), which is where the
// defect that produced this prop was found in the first place.
const HERE = path.dirname(fileURLToPath(import.meta.url))
test('no shell means no wrapper — the behaviour every page had before 1.5.0', () => {
// null, not an empty string: PublicLayout branches on it to render `children`
// bare, and '' would render a <div class=""> that changes core's nine pages.
assert.equal(shellClass(undefined), null)
assert.equal(shellClass(null), null)
assert.equal(shellClass(''), null)
assert.equal(shellClass(false), null)
})
test('each documented width maps to its theme.css class, plus page-body', () => {
assert.equal(shellClass('narrow'), 'shell-narrow page-body')
assert.equal(shellClass('mid'), 'shell-mid page-body')
assert.equal(shellClass('wide'), 'shell-wide page-body')
})
test('page-body is always present — it is what pushes the footer down', () => {
// `.page` is a flex column and `.page-body { flex: 1 }` is the only thing
// filling it. A width class on its own centres the content and still lets the
// footer ride up under it, which is half the reported defect and the half that
// is easy to lose in a refactor.
for (const w of SHELL_WIDTHS) {
assert.match(shellClass(w), /\bpage-body\b/)
}
})
test('an unknown width still renders a wrapper, at the narrow default', () => {
// The value can arrive from a module built against a different version of this
// list, so the failure mode has to be "wrong width" and never "no wrapper".
assert.equal(shellClass('enormous'), 'shell-narrow page-body')
assert.equal(shellClass(true), 'shell-narrow page-body')
assert.equal(shellClass('NARROW'), 'shell-narrow page-body')
})
test('every width this module offers is a class theme.css actually defines', () => {
// The contract now names these widths to module authors, so a rename in
// theme.css has to fail here rather than silently in a module's page.
const css = fs.readFileSync(path.join(HERE, '../src/styles/theme.css'), 'utf8')
for (const w of SHELL_WIDTHS) {
const cls = shellClass(w).split(' ')[0]
assert.ok(css.includes(`.${cls} {`), `theme.css defines .${cls}`)
}
assert.ok(css.includes('.page-body {'), 'theme.css defines .page-body')
})

View File

@@ -1,140 +0,0 @@
// 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

@@ -1,162 +0,0 @@
// What the public Team pages say (docs/website/TEAMS.md §3.2, §3.3, §4.3).
//
// lib/teams.js is plain JS precisely so these can be asserted without a DOM. The
// cases worth protecting are the ones where a wrong sentence is a false statement
// about the game rather than a cosmetic slip — an empty roster reported as "no
// members" when the module could not be asked being the clearest.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
LINK_STATE,
activityScopeNote,
emptyRosterReason,
filterTeams,
freshnessNote,
groupByDay,
linkStateOf,
relativeTime,
rosterSummary,
sortTeams,
} from '../src/lib/teams.js'
// ── The roster header ──────────────────────────────────────────────────────
test('the header states what each number is, so the gap reads as information', () => {
assert.equal(rosterSummary({ members: 37, linked: 21 }), '37 members · 21 linked')
})
test('one member is not "1 members"', () => {
assert.equal(rosterSummary({ members: 1, linked: 1 }), '1 member · 1 linked')
})
test('forum guests appear only once there are some', () => {
assert.equal(rosterSummary({ members: 37, linked: 21, guests: 0 }), '37 members · 21 linked')
assert.equal(rosterSummary({ members: 37, linked: 21, guests: 4 }), '37 members · 21 linked · 4 forum guests')
assert.equal(rosterSummary({ members: 2, linked: 1, guests: 1 }), '2 members · 1 linked · 1 forum guest')
})
test('an absent roster still produces a sentence rather than NaN', () => {
assert.equal(rosterSummary(), '0 members · 0 linked')
})
test('link state is a value, not an absence', () => {
assert.equal(linkStateOf({ linked: true }), LINK_STATE.linked)
assert.equal(linkStateOf({ linked: false }), LINK_STATE.unlinked)
assert.equal(linkStateOf(undefined), LINK_STATE.unlinked)
})
// ── Freshness ──────────────────────────────────────────────────────────────
const NOW = new Date('2026-08-17T12:00:00Z').getTime()
const ago = (ms) => new Date(NOW - ms).toISOString()
test('a deployment with no provider is not stale, it is uninvolved', () => {
assert.equal(freshnessNote({ configured: false }, NOW), null)
})
test('never synced is a warning, and never reads as a confirmed empty shard', () => {
const note = freshnessNote({ configured: true, lastSyncAt: null }, NOW)
assert.equal(note.tone, 'warn')
assert.match(note.text, /Not yet confirmed/)
})
test('a stale projection says how old it is and that the game may have moved on', () => {
const note = freshnessNote({ configured: true, lastSyncAt: ago(14 * 60_000), stale: true }, NOW)
assert.equal(note.tone, 'warn')
assert.equal(note.text, 'Last confirmed 14 minutes ago — the game may have moved on.')
})
test('a current projection is stated quietly', () => {
const note = freshnessNote({ configured: true, lastSyncAt: ago(90_000), stale: false }, NOW)
assert.equal(note.tone, 'idle')
assert.equal(note.text, 'Last confirmed 1 minute ago.')
})
test('relative time singularises and steps through the units', () => {
assert.equal(relativeTime(ago(5_000), NOW), 'just now')
assert.equal(relativeTime(ago(60_000), NOW), '1 minute ago')
assert.equal(relativeTime(ago(3 * 3_600_000), NOW), '3 hours ago')
assert.equal(relativeTime(ago(2 * 86_400_000), NOW), '2 days ago')
assert.equal(relativeTime(null, NOW), null)
assert.equal(relativeTime('not a date', NOW), null)
})
// ── Why a roster is empty ──────────────────────────────────────────────────
test('a populated roster has no explaining to do', () => {
assert.equal(emptyRosterReason({ members: [{}] }), null)
})
test('a module that could not be asked is never reported as an empty guild', () => {
// The failure this function exists to prevent: saying something false about
// the game because core could not reach the module.
const reason = emptyRosterReason({ members: [], projectionUnavailable: true })
assert.match(reason, /could not be reached/)
})
test('an unconfirmed projection says so rather than claiming the Team is empty', () => {
const reason = emptyRosterReason({ members: [], configured: true, lastSyncAt: null })
assert.match(reason, /not been confirmed/)
})
test('a genuinely empty, confirmed roster says the plain thing', () => {
const reason = emptyRosterReason({ members: [], configured: true, lastSyncAt: ago(1000) })
assert.equal(reason, 'Nobody is in this Team.')
})
// ── The activity feed ──────────────────────────────────────────────────────
test('items group into days, newest day first, order kept within a day', () => {
const days = groupByDay([
{ id: 3, occurredAt: '2026-08-17T09:00:00' },
{ id: 2, occurredAt: '2026-08-17T08:00:00' },
{ id: 1, occurredAt: '2026-08-16T22:00:00' },
], 'en-US')
assert.equal(days.length, 2)
assert.deepEqual(days[0].items.map((i) => i.id), [3, 2])
assert.deepEqual(days[1].items.map((i) => i.id), [1])
})
test('an unparseable timestamp is skipped rather than making a day called Invalid Date', () => {
const days = groupByDay([{ id: 1, occurredAt: 'nonsense' }], 'en-US')
assert.deepEqual(days, [])
})
test('a caller who saw everything is told nothing', () => {
assert.equal(activityScopeNote({ scope: 'members' }, true), null)
})
test('a filtered feed says so, and invites an anonymous caller to sign in', () => {
assert.match(activityScopeNote({ scope: 'public' }, false), /Sign in/)
assert.match(activityScopeNote({ scope: 'public' }, true), /members of this Team only/)
})
// ── The index ──────────────────────────────────────────────────────────────
test('teams sort by size then by name', () => {
const sorted = sortTeams([
{ name: 'Zephyr', memberCount: 3 },
{ name: 'Anvil', memberCount: 10 },
{ name: 'Bell', memberCount: 3 },
])
assert.deepEqual(sorted.map((t) => t.name), ['Anvil', 'Bell', 'Zephyr'])
})
test('sorting does not mutate its input', () => {
const input = [{ name: 'B', memberCount: 1 }, { name: 'A', memberCount: 9 }]
sortTeams(input)
assert.equal(input[0].name, 'B')
})
test('search matches the two things a visitor knows a Team by', () => {
const teams = [{ name: 'The Silver Hand', abbr: 'TSH' }, { name: 'Anvil', abbr: 'ANV' }]
assert.deepEqual(filterTeams(teams, 'silver').map((t) => t.abbr), ['TSH'])
assert.deepEqual(filterTeams(teams, 'anv').map((t) => t.abbr), ['ANV'])
assert.equal(filterTeams(teams, ' ').length, 2)
assert.equal(filterTeams(teams, 'nothing').length, 0)
})
test('search survives a team with no abbreviation', () => {
assert.doesNotThrow(() => filterTeams([{ name: 'Anvil', abbr: null }], 'a'))
})

View File

@@ -845,252 +845,6 @@ 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;
-- The per-Team activity feed (TEAMS.md §4.2, phase 3). Two writers, one table:
-- core writes its own membership and rename items with source='core', and a module
-- pushes game items through ctx.teams.activity.push with source=<moduleId>. That
-- core writes here too is deliberate — the rendering path is exercised by core's
-- own content from day one, so the feed is never empty on a deployment whose
-- module pushes nothing.
--
-- `summary` is ALREADY-RENDERED text and core never composes one (§4.1). Core
-- cannot phrase "gained 15,000 gold" for a game whose vocabulary it does not know,
-- and a core that templated it would have re-acquired exactly the game semantics
-- the module system exists to remove. `kind` and `payload` are likewise opaque:
-- core stores and filters them, and only the module's `team.overview` slot renders
-- anything richer than the text.
CREATE TABLE IF NOT EXISTS team_activity (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
team_id INT NOT NULL,
source VARCHAR(32) NOT NULL, -- 'core' or a module id
kind VARCHAR(64) NOT NULL, -- namespaced <source>.<name>, opaque to core
summary VARCHAR(255) NOT NULL, -- module-rendered; core never composes one
-- Defaults to 'members' — fail closed. The module CHOOSES visibility per item;
-- core ENFORCES it on the read path. Same shape as a module owning the
-- public-safety filter for its push streams (MODULE_API.md §2.4).
visibility ENUM('public','members') NOT NULL DEFAULT 'members',
actor_member_key VARCHAR(191) NULL,
actor_user_id INT NULL,
payload JSON NULL, -- opaque; rendered only by the module's slot
occurred_at DATETIME NOT NULL, -- when it happened in the game, not when it arrived
-- Optional idempotence key. INSERT IGNORE against this unique index is the same
-- trick shard_events already uses, and it is what makes a sidecar reconnect
-- backfill safe: replaying a window of events re-posts nothing.
dedupe_key CHAR(40) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_team_activity_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
-- Actor is SET NULL, not CASCADE (§2.10): deleting an account must not delete the
-- Team's history of what happened, only the attribution.
CONSTRAINT fk_team_activity_actor FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL,
UNIQUE KEY uq_team_activity_dedupe (team_id, dedupe_key),
INDEX idx_team_activity_feed (team_id, occurred_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,145 +730,6 @@
"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",
@@ -1638,24 +1499,6 @@
"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",
@@ -1713,40 +1556,6 @@
"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/activity",
"handlers": 3,
"gates": [
"siteMode",
"optionalAuth"
]
},
{
"method": "GET",
"path": "/api/v1/public/teams/:slug/members",
"handlers": 3,
"gates": [
"siteMode",
"optionalAuth"
]
},
{
"method": "GET",
"path": "/api/v1/public/version",

View File

@@ -293,58 +293,6 @@
"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"
@@ -657,14 +605,6 @@
"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"
@@ -697,22 +637,6 @@
"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/activity"
},
{
"method": "GET",
"path": "/api/v1/public/teams/:slug/members"
},
{
"method": "GET",
"path": "/api/v1/public/version"

View File

@@ -79,44 +79,6 @@ async function requireAuth(req, res, next) {
}
}
// Best-effort AUTHENTICATION, as opposed to attachSession's best-effort decode.
//
// For a PUBLIC route whose content — not merely its presentation — depends on who
// is asking. The Team activity feed is the first: `public` items go to everyone
// and `members` items only to members and forum-granted users (TEAMS.md §4.3), so
// an anonymous caller must be served, not rejected, and an authenticated one must
// be identified properly.
//
// "Properly" is why this is not attachSession. That one decodes the token and
// stops, which is right for reading back your own session but wrong here: a
// banned account, a password change, or a logout would all keep working against
// the private half of the feed until the JWT expired. This runs the same
// database re-validation requireAuth does — status, cutoff, revocation — and on
// any failure continues ANONYMOUSLY rather than 401ing. A caller whose session is
// no longer good sees the public feed, which is exactly what they are entitled to.
//
// A database error also degrades to anonymous. On a public route the safe
// direction is to serve less, and 500ing a page because a session lookup failed
// would take the whole Team page down for callers who never sent a token.
async function optionalAuth(req, res, next) {
const session = sessionService.validateSession(req)
if (!session) return next()
try {
const user = await users.getById(session.userId)
if (!user) return next()
if (user.status && user.status !== 'active') return next()
if (isBeforeCutoff(session, user.tokens_valid_after)) return next()
if (await sessionService.isSessionRevoked(session.sessionId)) return next()
req.user = user
req.session = session
req.authMethod = session.authMethod
} catch (err) {
log.warn('optionalAuth: continuing anonymously', { message: err.message })
}
return next()
}
// Gate middleware factory: allow only the listed roles. Assumes requireAuth ran
// first so req.user is populated. Use for admin-only endpoints (users, site
// mode, settings) so a lower-privilege editor cannot reach them.
@@ -129,7 +91,6 @@ function requireRole(...roles) {
module.exports = {
attachSession,
optionalAuth,
requireAuth,
requireRole,
}

View File

@@ -1,94 +0,0 @@
// 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

@@ -1,131 +0,0 @@
// ── 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

@@ -1,133 +0,0 @@
// SQL for the per-Team activity feed (TEAMS.md §4.2). Statements only; every
// decision about what a caller may SEE lives in teamActivity.model.js.
const { query } = require('../../utils/db')
const ACTIVITY_COLUMNS = `
id, team_id, source, kind, summary, visibility,
actor_member_key, actor_user_id, payload, occurred_at, created_at`
/**
* Insert one item, idempotently when it carries a dedupe key.
*
* INSERT IGNORE against uq_team_activity_dedupe is what makes replay safe: a
* sidecar reconnect backfills a window of events it already delivered, and
* without this every reconnect would double-post the feed. The same trick
* `shard_events` uses, for the same reason.
*
* The unique key is (team_id, dedupe_key) and MariaDB treats NULL as distinct in
* a unique index, so items WITHOUT a key never collide with each other — an
* un-keyed push is always an insert, which is the documented contract (§4.1:
* `dedupeKey` is optional and "makes replay idempotent", so omitting it opts out).
*
* IGNORE would also swallow a genuine error — a bad FK, an over-long summary. The
* model validates and truncates before calling, so what reaches here can only fail
* on the dedupe key, and `affectedRows` reports which happened.
*/
async function insert(item) {
const res = await query(
`INSERT IGNORE INTO team_activity
(team_id, source, kind, summary, visibility, actor_member_key, actor_user_id, payload, occurred_at, dedupe_key)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
item.teamId,
item.source,
item.kind,
item.summary,
item.visibility,
item.actorMemberKey,
item.actorUserId,
item.payload === null ? null : JSON.stringify(item.payload),
new Date(item.occurredAt),
item.dedupeKey,
],
)
return Number(res.affectedRows) > 0
}
/**
* One page of a Team's feed, already narrowed to the visibilities the caller may
* see.
*
* `visibilities` is always supplied by the model and never by a request
* parameter — a caller naming its own visibility filter is the whole bug this
* table's ENUM exists to prevent. Ordered newest first by `occurred_at`, the
* game's clock, not `created_at`: a backfill that arrives late still sorts where
* it happened.
*/
async function page(teamId, visibilities, { limit, offset }) {
const slots = visibilities.map(() => '?').join(', ')
return query(
`SELECT ${ACTIVITY_COLUMNS} FROM team_activity
WHERE team_id = ? AND visibility IN (${slots})
ORDER BY occurred_at DESC, id DESC
LIMIT ? OFFSET ?`,
[teamId, ...visibilities, limit, offset],
)
}
/** Total matching rows, for the same filter — so a client can page honestly. */
async function count(teamId, visibilities) {
const slots = visibilities.map(() => '?').join(', ')
const rows = await query(
`SELECT COUNT(*) AS n FROM team_activity WHERE team_id = ? AND visibility IN (${slots})`,
[teamId, ...visibilities],
)
return Number(rows[0] ? rows[0].n : 0)
}
/** Everything older than the retention horizon, across every Team. */
async function deleteOlderThan(days) {
const res = await query(
'DELETE FROM team_activity WHERE occurred_at < (NOW() - INTERVAL ? DAY)',
[days],
)
return Number(res.affectedRows) || 0
}
/**
* Which Teams currently exceed the per-Team row cap, and by how much.
*
* Asked first so the trim only runs for Teams that need it. A feed fed by a game
* loop is the obvious unbounded-growth failure (§4.2), and on a shard with one
* busy guild and fifty quiet ones this keeps the nightly job proportional to the
* problem rather than to the number of Teams.
*/
async function overCap(cap) {
return query(
`SELECT team_id, COUNT(*) AS n FROM team_activity
GROUP BY team_id HAVING n > ?`,
[cap],
)
}
/**
* Trim one Team back to the newest `cap` rows.
*
* Expressed as "delete everything at or below the id of the cap-th newest row"
* rather than as a correlated subquery on the same table, which MariaDB refuses
* inside a DELETE (error 1093). The derived table is what makes it legal — the
* subquery is materialised before the delete runs.
*/
async function trimToCap(teamId, cap) {
const rows = await query(
`SELECT id FROM team_activity
WHERE team_id = ? ORDER BY occurred_at DESC, id DESC LIMIT 1 OFFSET ?`,
[teamId, cap],
)
if (!rows[0]) return 0
const res = await query(
'DELETE FROM team_activity WHERE team_id = ? AND id <= ?',
[teamId, rows[0].id],
)
return Number(res.affectedRows) || 0
}
module.exports = {
insert,
page,
count,
deleteOlderThan,
overCap,
trimToCap,
}

View File

@@ -1,312 +0,0 @@
// ── The per-Team activity feed (TEAMS.md Part 4) ───────────────────────────
//
// Two writers, one table. A module pushes game items through
// `ctx.teams.activity.push` (§4.1); core writes its own membership and rename
// items directly (§4.2). Both land in `team_activity` with a `source`, and the
// read path treats them identically — which is the point of core writing here at
// all, since it means the rendering path is exercised from day one on a
// deployment whose module pushes nothing.
//
// **Three rules shape this file.**
//
// 1. *Core never composes a summary.* `summary` arrives already rendered and is
// stored verbatim (§4.1). Core cannot phrase "gained 15,000 gold" for a game
// whose vocabulary it does not know, and a core that templated it would have
// re-acquired the game semantics the module system exists to remove. Core's OWN
// five kinds are the sole exception, and they are about membership and renames
// — platform facts, not game ones.
//
// 2. *Visibility fails closed.* An item with no stated visibility is `members`,
// and the read path resolves what a caller may see from their access rather
// than from anything they send.
//
// 3. *A push never throws at its call site.* `ctx.teams.activity.push` is awaited
// by a module inside a game-event handler. A bad item is dropped and logged;
// an unknown Team is dropped and logged. The alternative — rejecting the batch
// — makes core's storage problem into the module's control flow, and the
// contract (MODULE_API.md §2.3) is that ctx pushes are fire-and-forget.
const activityDb = require('./teamActivity.db')
const teamsDb = require('./teams.db')
const access = require('./teamAccess.model')
const settings = require('../settings/settings.model')
const log = require('../../utils/logger')('teams')
// Column widths from schema.sql. Truncating rather than refusing: an over-long
// summary is a module being verbose, not a module being wrong, and dropping the
// item would lose a real event over a display detail.
const MAX_SUMMARY = 255
const MAX_KIND = 64
const MAX_MEMBER_KEY = 191
// CHAR(40) — a sha1 hex is the natural fit and what §4.1's example looks like,
// but the column is opaque and any stable string within the width works.
const MAX_DEDUPE = 40
const VISIBILITIES = ['public', 'members']
// Retention (§4.2). Both are settings so an operator can tighten a busy shard
// without a deploy; the defaults are the doc's.
const DEFAULT_RETAIN_DAYS = 90
const DEFAULT_ROW_CAP = 2000
/**
* Core's own kinds (§4.2).
*
* `core.forum.thread` is named in the doc and lands with the forum in phase 4 —
* there is nothing to emit it from yet. The four here are all core knows how to
* say without asking a game anything.
*/
const CORE_KINDS = {
MEMBER_JOINED: 'core.member.joined',
MEMBER_LEFT: 'core.member.left',
LEADER_CHANGED: 'core.leader.changed',
TEAM_RENAMED: 'core.team.renamed',
}
const clamp = (v, max) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, max) : null)
/**
* Normalise one pushed item, or return null to drop it.
*
* `teamId` is resolved by the caller, not carried on the item: a module names its
* own `externalId` and core maps it (§4.1), so a module can never write into
* another module's Team by guessing an integer.
*/
function normalise(item, source, teamId) {
if (!item || typeof item !== 'object') return null
const kind = clamp(item.kind, MAX_KIND)
const summary = clamp(item.summary, MAX_SUMMARY)
// Both are load-bearing and neither has a safe default: an item with no kind
// cannot be filtered or rendered by a slot, and one with no summary is a blank
// row on a public page.
if (!kind || !summary) return null
// `occurredAt` is the game's clock and the feed's sort key. A missing or
// unparseable one becomes now — the item is real even when its timestamp is
// not, and dropping it would lose an event over metadata.
const occurredAt = Number.isFinite(item.occurredAt) ? Number(item.occurredAt) : Date.now()
return {
teamId,
source,
kind,
summary,
visibility: VISIBILITIES.includes(item.visibility) ? item.visibility : 'members',
actorMemberKey: clamp(item.actorMemberKey, MAX_MEMBER_KEY),
// Resolved BY THE MODULE, like every other user id crossing this boundary
// (§2.3) — core takes the number and never looks it up.
actorUserId: Number.isInteger(item.actorUserId) && item.actorUserId > 0 ? item.actorUserId : null,
payload: item.payload && typeof item.payload === 'object' ? item.payload : null,
occurredAt,
dedupeKey: clamp(item.dedupeKey, MAX_DEDUPE),
}
}
/**
* `ctx.teams.activity.push` — a module's whole write access to the feed.
*
* Items name their Team by the module's own `externalId`, and only ACTIVE Teams
* owned by THAT module resolve. An archived Team is deliberately not writable: its
* feed is a read-only record of what happened before the rename or the disband
* (§2.2), and letting a late-arriving event append to it would make a closed
* record grow.
*
* Returns the number of items actually stored. Dropped items are logged with the
* reason and never raised — see rule 3 above.
*/
async function push(source, items) {
if (!Array.isArray(items)) {
log.warn('teams activity push: not an array', { source })
return 0
}
if (!items.length) return 0
// One lookup per distinct externalId, not one per item: a champion spawn
// completing pushes a batch for a single Team, and re-resolving it per item
// would be a query per row.
const teamIds = new Map()
let stored = 0
let dropped = 0
for (const item of items) {
const externalId = item && typeof item.externalId === 'string' ? item.externalId.trim() : ''
if (!externalId) { dropped += 1; continue }
if (!teamIds.has(externalId)) {
// eslint-disable-next-line no-await-in-loop
const row = await teamsDb.findActive(source, externalId)
teamIds.set(externalId, row ? row.id : null)
}
const teamId = teamIds.get(externalId)
if (!teamId) { dropped += 1; continue }
const normalised = normalise(item, source, teamId)
if (!normalised) { dropped += 1; continue }
// eslint-disable-next-line no-await-in-loop
const inserted = await activityDb.insert(normalised)
// A dedupe collision is a SUCCESSFUL no-op, not a drop — it is the mechanism
// working. Counted as stored so a module replaying a backfill does not read
// its own idempotence as data loss.
if (inserted) stored += 1
}
if (dropped) {
log.warn('teams activity push: dropped items', { source, dropped, offered: items.length })
}
return stored
}
/**
* Core's own write path (§4.2), used by the reconciler and the rename rule.
*
* Separate from `push` because core names a Team by its own primary key — it is
* already holding the row — and because core's items are always `public`: a
* member joining or a Team being renamed is exactly what a public Team page is
* for. Nothing here is game vocabulary.
*/
async function logCore({ teamId, kind, summary, actorMemberKey = null, actorUserId = null, occurredAt = Date.now(), dedupeKey = null }) {
if (!teamId || !kind || !summary) return false
return activityDb.insert({
teamId,
source: 'core',
kind: clamp(kind, MAX_KIND),
summary: clamp(summary, MAX_SUMMARY),
visibility: 'public',
actorMemberKey: clamp(actorMemberKey, MAX_MEMBER_KEY),
actorUserId: Number.isInteger(actorUserId) && actorUserId > 0 ? actorUserId : null,
payload: null,
occurredAt,
dedupeKey: clamp(dedupeKey, MAX_DEDUPE),
})
}
/**
* Which visibilities a caller may see (§4.3).
*
* `members` items go to members and to forum-granted users — the same two
* authority paths `forumAccess` already resolves, reused rather than re-derived
* so the feed can never disagree with the forum about who is inside a Team.
* Anyone else, including every anonymous caller, sees `public` only.
*/
async function visibilitiesFor(teamId, userId) {
if (!userId) return ['public']
const resolved = await access.forumAccess(teamId, userId)
return resolved.allowed ? ['public', 'members'] : ['public']
}
/** The rendered shape. `payload` rides along for the module's slot (§4.3). */
function publicItem(row) {
return {
id: Number(row.id),
source: row.source,
kind: row.kind,
summary: row.summary,
visibility: row.visibility,
occurredAt: row.occurred_at,
payload: row.payload ?? null,
}
}
/**
* One page of a Team's feed for one viewer.
*
* A HIDDEN Team's feed is not served publicly, for the same reason its roster is
* not (§2.8.3): hidden means absent from every public surface, and a feed that
* answered while the page 404s would republish the suppressed name in every
* `core.team.renamed` summary.
*/
async function feedFor(slug, userId, { limit = 50, offset = 0 } = {}) {
const row = await teamsDb.findBySlug(slug)
if (!row) return null
const visibilities = await visibilitiesFor(row.id, userId)
// A member of a hidden Team still sees its feed — suppression is a
// public-surface rule, and a member is not a member of the public (§2.11).
if (row.hidden && visibilities.length === 1) return null
const [rows, total] = await Promise.all([
activityDb.page(row.id, visibilities, { limit, offset }),
activityDb.count(row.id, visibilities),
])
return {
items: rows.map(publicItem),
total,
limit,
offset,
// So a client can render "members-only items are hidden" rather than
// presenting a filtered feed as the whole one.
scope: visibilities.includes('members') ? 'members' : 'public',
}
}
// ── Retention (§4.2) ───────────────────────────────────────────────────────
const RETAIN_KEY = 'team_activity_retain_days'
const CAP_KEY = 'team_activity_row_cap'
/**
* Read both limits, falling back to the defaults on anything unreadable.
*
* Wrapped in a try like `teamSync.intervalSeconds`, and for the same reason: this
* runs on a timer with nobody watching, and a settings table that is briefly
* unavailable must yield the default rather than an exception that kills the
* nightly job. A misconfigured value fails the same way — a zero or a negative
* retention would delete the whole feed, so it is rejected rather than honoured.
*/
async function retentionConfig() {
let rawDays
let rawCap
try {
;[rawDays, rawCap] = await Promise.all([settings.get(RETAIN_KEY), settings.get(CAP_KEY)])
} catch {
return { days: DEFAULT_RETAIN_DAYS, cap: DEFAULT_ROW_CAP }
}
const days = Number.parseInt(rawDays, 10)
const cap = Number.parseInt(rawCap, 10)
return {
days: Number.isFinite(days) && days > 0 ? days : DEFAULT_RETAIN_DAYS,
cap: Number.isFinite(cap) && cap > 0 ? cap : DEFAULT_ROW_CAP,
}
}
/**
* The nightly prune: an age horizon AND a per-Team row cap.
*
* Both, because either alone has a hole. Age alone lets one busy guild write a
* million rows inside the window; a cap alone keeps a dead Team's feed forever.
* Unbounded growth on a per-Team feed fed by a game loop is the obvious failure
* here and it is cheaper to bound it now than to discover it at cutover.
*/
async function prune() {
const { days, cap } = await retentionConfig()
const byAge = await activityDb.deleteOlderThan(days)
let byCap = 0
const over = await activityDb.overCap(cap)
for (const row of over) {
// eslint-disable-next-line no-await-in-loop
byCap += await activityDb.trimToCap(row.team_id, cap)
}
if (byAge || byCap) log.info('teams activity prune', { byAge, byCap, days, cap })
return { byAge, byCap, days, cap }
}
module.exports = {
push,
logCore,
feedFor,
visibilitiesFor,
publicItem,
prune,
retentionConfig,
RETAIN_KEY,
CAP_KEY,
CORE_KINDS,
VISIBILITIES,
DEFAULT_RETAIN_DAYS,
DEFAULT_ROW_CAP,
}

View File

@@ -1,123 +0,0 @@
// 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

@@ -1,239 +0,0 @@
// ── 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

@@ -1,239 +0,0 @@
// ── 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 }
}
/**
* `{ ok, members: [memberKey] }` — WHICH rows the module permits this viewer.
*
* Deliberately a set of keys rather than a set of rows. Core already holds the
* rows and knows their public shape; asking the module for rows back would let a
* module widen what is published — re-adding a `userId` or a `memberKey` that
* §3.2 says is never published — and core's field guarantee would then rest on
* every module's good behaviour rather than on core. So the module answers the
* question it actually owns (who may be seen at this rung) and core keeps the
* question it owns (what a member row looks like in public).
*/
function normaliseVisibleKeys(answer) {
if (!Array.isArray(answer.members)) return fail('projectRoster() answered ok with no members array')
const keys = []
for (const raw of answer.members) {
const key = str(raw)
if (!key) return fail('a projectRoster() entry is not a member key')
if (!keys.includes(key)) keys.push(key)
}
return { ok: true, members: keys }
}
const getTeams = () => call('getTeams', normaliseTeams)
const getTeamMembers = (externalId) => call('getTeamMembers', normaliseMembers, externalId)
const getTeamLeaders = (externalId) => call('getTeamLeaders', normaliseLeaders, externalId)
/**
* Ask the module which roster rows this viewer may see (§3.3).
*
* The per-audience projection is the module's because the visibility framework
* and its rung configuration are module-owned (§10.5) — core does not know what a
* rung is. Core supplies the roster and a description of the viewer; the module
* returns the member keys it permits.
*
* **"No audience model" and "could not answer" are different, and the caller must
* be able to tell them apart** — so the refusal carries `projects`.
*
* `projects: false` — no provider is registered, or the registered one does not
* implement `projectRoster`. There is no rung system to consult and nothing
* is being withheld; the roster is served at core's public shape. This is why
* the member is OPTIONAL: bare core, and a module with no audience model of
* its own, both render exactly the page core writes.
*
* `projects: true` — the module HAS an audience model and core could not reach
* it (refused, threw, timed out, answered malformed). Here the caller must
* fail CLOSED, because "leave it alone" would mean publishing the very rows
* the rungs exist to withhold. This is the one place in the Team subsystem
* where unavailability is not staleness: everywhere else a refused call
* leaves data alone, and doing that to a *visibility* question is a leak.
*/
async function projectRoster(externalId, members, viewer) {
const provider = registries.registeredTeamProvider()
if (!provider) return { ...fail('no team provider is registered'), projects: false }
if (typeof provider.projectRoster !== 'function') {
return { ...fail('provider does not project rosters'), projects: false }
}
const answer = await call('projectRoster', normaliseVisibleKeys, externalId, members, viewer)
return answer.ok ? answer : { ...answer, projects: true }
}
/** 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,
projectRoster,
providerModuleId,
CALL_TIMEOUT_MS,
}

View File

@@ -1,50 +0,0 @@
// 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

@@ -1,581 +0,0 @@
// ── 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 activity = require('./teamActivity.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,
})
// §4.2's `core.team.renamed`, written to the SUCCESSOR rather than to the row
// that was renamed: the archived row is a read-only record of what happened
// before the rename (§2.2), and the person who wants to know a Team used to be
// called something else is looking at the live page.
//
// The old name is core's own, not game-sourced text a module handed us this
// run — it is the `name` column core has been serving all along — so §2.9's
// approval gate does not apply. It can still be a name staff suppressed, which
// is why a hidden Team's feed is not served publicly (teamActivity.feedFor).
await activity.logCore({
teamId: successorId,
kind: activity.CORE_KINDS.TEAM_RENAMED,
summary: `Renamed from ${existing.display_name_override || existing.name}`,
dedupeKey: `renamed:${existing.id}`,
}).catch((err) => log.warn('rename activity not recorded', { message: err.message }))
return successorId
}
/** Never a game-internal member key on a public page: that identifier is not published (§3.2). */
const memberLabel = (row) => (row && row.display_name) || 'A member'
/**
* Core's own membership items for one roster run (§4.2).
*
* **Suppressed on a Team's FIRST roster.** Importing a 155-member guild is one
* Team arriving, not 155 people joining, and emitting a join per member would
* bury every real event under the import and blow through the row cap on day one.
* `roster_synced_at IS NULL` is exactly "core has never held a roster for this
* Team", so the same condition covers a newly created Team and a newly installed
* module adopting an existing one.
*
* Never throws: the feed is a rendering of the sync, and a feed write failing
* must not abort the sync that is the actual source of truth.
*/
async function logRosterActivity(team, { joined, left, promoted, demoted }) {
if (!team.roster_synced_at) return
const items = [
...joined.map((row) => ({ kind: activity.CORE_KINDS.MEMBER_JOINED, row, verb: 'joined' })),
...left.map((row) => ({ kind: activity.CORE_KINDS.MEMBER_LEFT, row, verb: 'left' })),
...promoted.map((row) => ({ kind: activity.CORE_KINDS.LEADER_CHANGED, row, verb: 'became a leader' })),
...demoted.map((row) => ({ kind: activity.CORE_KINDS.LEADER_CHANGED, row, verb: 'stepped down as a leader' })),
]
for (const { kind, row, verb } of items) {
try {
// eslint-disable-next-line no-await-in-loop
await activity.logCore({
teamId: team.id,
kind,
summary: `${memberLabel(row)} ${verb}`,
actorMemberKey: row.member_key,
actorUserId: row.user_id ?? null,
})
} catch (err) {
log.warn('roster activity not recorded', { teamId: team.id, kind, message: err.message })
}
}
}
/**
* 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
}
// The full rows rather than just the keys: the activity feed needs the display
// name and the prior `is_leader` of everyone who is about to change, and both
// are gone once the upsert below has run. One read either way — this replaces
// the `memberKeys` call rather than adding to it.
const knownRows = await teamsDb.membersByTeam(team.id)
const knownByKey = new Map(knownRows.map((row) => [row.member_key, row]))
const known = knownRows.map((row) => row.member_key)
// 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,
})
}
// Anyone the module reports that core was not already holding. Read from the
// module's shape, since a joiner has no row yet.
const joined = answer.members
.filter((m) => !knownByKey.has(m.memberKey))
.map((m) => ({ member_key: m.memberKey, display_name: m.displayName, user_id: m.userId }))
// Removals only from a COMPLETE answer. `complete: false` means "valid but
// partial", so additions and updates apply and nothing is taken away.
let left = []
if (answer.complete) {
const seen = new Set(answer.members.map((m) => m.memberKey))
const departedKeys = known.filter((key) => !seen.has(key))
left = departedKeys.map((key) => knownByKey.get(key))
await teamsDb.markDeparted(team.id, departedKeys)
}
// 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)
let promoted = []
let demoted = []
if (leaders.ok) {
// Diffed against the PRIOR rows, before setLeaders overwrites them. A member
// who joined this run as a leader is reported as joining, not as being
// promoted — they were never anything else here.
const nowLeader = new Set(leaders.leaders)
const departed = new Set(left.map((row) => row && row.member_key))
promoted = knownRows.filter((row) => nowLeader.has(row.member_key) && !row.is_leader)
demoted = knownRows.filter((row) => !nowLeader.has(row.member_key) && row.is_leader && !departed.has(row.member_key))
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)
// Read before `markRosterSynced` moves the stamp this decision turns on.
await logRosterActivity(team, { joined, left: left.filter(Boolean), promoted, demoted })
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

@@ -1,312 +0,0 @@
// 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

@@ -1,355 +0,0 @@
// ── 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,
// What the `teams` nav feature flag resolves from (§3.5). True if a provider
// is registered OR any Team exists — the second half matters because Team
// rows outlive the module that filled them, and hiding the nav entry the
// moment a module is uninstalled would make every existing Team page
// unreachable from the site while still answering by URL.
//
// False only when there is nothing and no prospect of anything, which is
// exactly the bare-core case the flag exists for: a link to a permanently
// empty page is worse than no link.
enabled: Boolean(sync.configured) || visible.length > 0,
}
}
/**
* 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),
// The three props the `team.overview` extension slot is declared with
// (§3.4). A module's slot component runs in the browser and has to know
// WHICH Team it is looking at, in its own vocabulary — `slug` is core's name
// for it and resolves nothing on the module's side.
//
// On this route only, deliberately: the index has no slot and would
// otherwise publish a module-internal identifier per row for nothing. None
// of the three names a person — they are a core row id, a game-side group
// id and a module name, and the identifiers §3.2 withholds (member keys,
// site account ids) are not among them.
id: row.id,
externalId: row.external_id,
moduleId: row.module_id,
...sync,
successor: successor && !successor.hidden
? { slug: successor.slug, name: successor.display_name_override || successor.name }
: null,
}
}
/**
* A Team's roster, projected for the caller's audience rung (§3.3).
*
* The ROW filter is the module's: it owns the visibility framework and its
* configuration (§10.5), and core does not know what a rung is. The FIELD shape
* stays core's — every row that survives goes through `publicMember`, which
* withholds the member key and the user id whatever the module answers. So a
* module can narrow what is published and cannot widen it, and core's "neither is
* published" guarantee does not rest on every module's good behaviour.
*
* **A module that HAS a rung system and cannot answer withholds the roster.** That
* is the one Team call where a refusal is not staleness: leaving a visibility
* answer "alone" would publish the very rows the rungs exist to withhold. A
* deployment with no module, or one whose module does not project at all, is a
* different case entirely — nothing is being withheld there, so the roster is
* served whole at core's public shape (`projects: false`).
*/
async function rosterPublic(slug, viewer = null) {
const row = await teamsDb.findBySlug(slug)
if (!row || row.hidden) return null
const [members, sync] = await Promise.all([
access.rosterWithOverrides(row.id),
syncStatus(),
])
// The module gets the rows as it supplied them — this is its own data coming
// home — plus who is asking, which is all a rung decision needs.
const answer = await teamProvider.projectRoster(row.external_id, members, viewer)
let visible
if (answer.ok) visible = members.filter((m) => answer.members.includes(m.member_key))
else if (answer.projects) visible = [] // fail closed: it has rungs and we could not ask
else visible = members // nothing to fail closed ABOUT
return {
members: visible.map(publicMember),
...sync,
rosterSyncedAt: row.roster_synced_at,
// Stated rather than implied. An empty roster has three quite different
// causes — a Team with no members, a rung that shows none, and a module that
// could not be asked — and a page that cannot tell them apart will report the
// last one as the first.
projected: answer.ok,
...(answer.ok || !answer.projects ? {} : { projectionUnavailable: true }),
}
}
// ── 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,15 +170,6 @@ 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,8 +119,6 @@ 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 teamActivity = require('../model/teams/teamActivity.model')
const { makeLimiter, accountChangeLimiter } = require('../middleware/rateLimit')
/* eslint-enable global-require */
@@ -176,37 +174,6 @@ 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 (phase 3). `source` is bound to the CALLING module and
// is never taken from the item — a module writes its own items, under its
// own name, and items name their Team by the module's own `externalId`, so
// there is no id a module could send that reaches another module's Team.
//
// Like `publish` and `reconcile` above, a failure here never reaches the
// module: this is called from inside a game-event handler, and a storage
// problem of core's must not become the module's control flow. A rejected
// write is logged and the promise still resolves.
activity: {
push: (items) => teamActivity.push(id, items).then(
(stored) => { void stored },
(err) => { log.error('ctx.teams.activity.push failed', { module: id, message: err.message }) },
),
},
},
// 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
@@ -273,23 +240,6 @@ 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,16 +58,6 @@ 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
@@ -206,14 +196,6 @@ 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
@@ -243,40 +225,6 @@ function checkLegShape(entry) {
return { leg, label: label || leg, dispatch, classify }
}
// 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.
//
// `projectRoster` is the fourth and is OPTIONAL (TEAMS.md §3.3): it expresses an
// audience model, and a module with no rung system of its own has no opinion to
// express. Omitting it means core serves rosters at its own public shape;
// implementing it means core fails CLOSED when the call cannot be made, so this
// is a member to add deliberately rather than by habit.
//
// The copy is explicit rather than a spread: this object is what core calls, so
// anything not named here is not part of the contract and must not survive
// registration. A method that silently rode along would look implemented from the
// module's side and be invisible from core's.
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]
}
if (provider.projectRoster !== undefined) {
if (typeof provider.projectRoster !== 'function') {
throw new Error('registerTeamProvider: projectRoster must be a function if present')
}
out.projectRoster = provider.projectRoster
}
return out
}
/**
* `registerPostHook({ onSaved, onDeleted })` — both optional, at least one
* required. A registration with neither is a subscription that can never fire,
@@ -317,7 +265,7 @@ function checkExtensionShape(slot, router, specFile) {
* `allStreams()` / `announceLeg()` / the slot routers until `apply()`.
*/
function stage(owner) {
const staged = { owner, streams: [], legs: [], extensions: [], postHooks: [], teamProviders: [] }
const staged = { owner, streams: [], legs: [], extensions: [], postHooks: [] }
return {
staged,
registerNotificationStreams(entries) {
@@ -333,9 +281,6 @@ function stage(owner) {
registerPostHook(entry) {
staged.postHooks.push(checkPostHookShape(entry))
},
registerTeamProvider(entry) {
staged.teamProviders.push(checkTeamProviderShape(entry))
},
}
}
@@ -348,14 +293,7 @@ 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 = [],
teamProviders: newTeamProviders = [],
}) {
function apply({ owner, streams: newStreams, legs: newLegs, extensions: newExtensions, postHooks: newPostHooks = [] }) {
// ── validate ──
const seenStreams = new Set()
for (const s of newStreams) {
@@ -394,11 +332,6 @@ function apply({
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)
@@ -412,7 +345,6 @@ function apply({
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 ───────────────────────────────────────────────
@@ -478,7 +410,6 @@ function _reset() {
streamOwners.clear()
legs.clear()
postHooks.clear()
teamProvider = null
coreRegistered = false
}
@@ -496,8 +427,6 @@ module.exports = {
announceLeg,
postHookEntries,
dispatchPostHook,
registeredTeamProvider,
hasTeamProvider,
stage,
apply,
registerCore,

View File

@@ -9,39 +9,6 @@
// 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
// bump because that breaks a call already written, and adding an optional one
// breaks nothing — omitting `shell` is 1.4.0's behaviour exactly. Nothing on the
// server changed; this file bumps for the reason below. Found by the Integration
// Kit's acceptance run (docs/modules/kit-acceptance.md), where a module built
// exactly as the kit teaches rendered outside the site's page column.
//
// 1.4.0 — no member changed. §2.7 gained one prohibition: a module does not open
// a connection to a game server from the website process; it talks to a sidecar,
// which owns the durable copy of the game's state. Minor rather than major
// because the SURFACE is identical to 1.3.0 — module-uo's `coreApi: "^1.3.0"`
// still resolves, and it already complies — but a module written against 1.3.0
// could satisfy every member and still be built the wrong way round, which is
// what this number now says. The one §2.7 rule with no CI behind it: an outbound
// socket is not statically detectable the way an internal require is.
//
// 1.3.0 — three CLIENT additions from Phase 3 slice 3: a nav item may carry an
// `icon`, core declares a `player.invite.accepted` slot, and `window.__rg.api`
// gained `BASE` (which §3.5 always specified and shared.js never published).
@@ -59,6 +26,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.6.0'
const MODULE_API_VERSION = '1.3.0'
module.exports = { MODULE_API_VERSION }

View File

@@ -31,7 +31,6 @@ 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()
@@ -80,11 +79,6 @@ 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

@@ -265,13 +265,9 @@ async function disable(req, res) {
// this is about to delete, so after an uninstall there is nothing left to purge
// with. Ticking the box is the last moment the file exists.
//
// The order below is the whole of it: locate the purge file, STOP, purge, then
// delete. Only the last step is destructive to the filesystem, so the file stays
// readable the whole way through — which is why stopping comes first. Dropping a
// module's tables while it is still started leaves it serving and ingesting
// against a schema that no longer exists: module-uo's uo-link WebSocket keeps
// writing shard events for as long as its onShutdown takes to run, and requests
// in flight answer 500 where a stopped module answers 404.
// The order below is the whole of it, and each step depends on the one above:
// purge while the SQL is still readable, stop while the code is still loaded,
// then delete.
async function remove(req, res) {
const { id } = req.params
const purge = req.query.purge === 'true' || req.query.purge === '1'
@@ -280,26 +276,23 @@ async function remove(req, res) {
const onVolume = install.isInstalled(id)
if (!current && !onVolume) return res.status(404).json({ message: 'No such module.' })
// Resolved before anything happens, because this branch is a 400: a request
// that is going to be refused must not stop the module on its way out.
let purgeSql = null
let purged = null
if (purge) {
purgeSql = install.purgeFile(id)
if (!purgeSql) {
const file = install.purgeFile(id)
if (!file) {
return res.status(400).json({
message: 'This module ships no purge.sql, so its data cannot be deleted. Uninstall without purging instead.',
})
}
purged = await schema.runPurge(file)
}
// Stop it before its tables and then its files vanish. A module whose
// directory is deleted out from under a running onShutdown is being asked to
// tear down a world whose code may already be half-unreadable — and its
// sockets would otherwise stay open until the restart, holding a connection
// on behalf of a module that no longer exists on disk.
// Stop it before its files vanish. A module whose directory is deleted out
// from under a running onShutdown is being asked to tear down a world whose
// code may already be half-unreadable — and its sockets would otherwise stay
// open until the restart, holding a connection on behalf of a module that no
// longer exists on disk.
await lifecycle.stop(id)
const purged = purgeSql ? await schema.runPurge(purgeSql) : null
const removed = await install.removeDir(id)
// A purge leaves nothing: no directory, no tables, no data. Keeping a

View File

@@ -1,211 +0,0 @@
// 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

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

View File

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

View File

@@ -1,28 +0,0 @@
// 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

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

View File

@@ -21,7 +21,6 @@ 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()
@@ -37,10 +36,6 @@ 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

@@ -1,82 +0,0 @@
// 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 teamActivity = require('../../../model/teams/teamActivity.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')
}
}
/**
* The roster, projected for whoever is asking (§3.3).
*
* The viewer is described to the module rather than handed over: it gets the
* caller's id and role, which is what a rung decision turns on, and not the user
* row — a module has `ctx.users.getById` if it needs more, and passing the whole
* record here would make every column of `users` part of this contract.
*/
async function getRoster(req, res) {
try {
const viewer = req.user ? { userId: req.user.id, role: req.user.role } : null
const roster = await teams.rosterPublic(req.params.slug, viewer)
if (!roster) return res.status(404).json({ message: 'Team not found' })
return res.json(roster)
} catch (err) {
return fail(res, err, 'roster')
}
}
/**
* A Team's activity feed (§4.3).
*
* The only handler in this tier that reads `req.user`, and it reads nothing else
* from the caller about what they may see: `limit` and `offset` are page
* controls, and the visibility filter is resolved from the session alone. A
* request parameter naming its own visibility is the bug the ENUM exists to
* prevent, so there is deliberately no way to ask for one.
*
* The cap is 100 rather than the index's 200 — every row carries a summary and an
* opaque payload, so a page of these is much larger than a page of Teams.
*/
async function getActivity(req, res) {
try {
const limit = Math.min(Math.max(Number.parseInt(req.query.limit, 10) || 50, 1), 100)
const offset = Math.max(Number.parseInt(req.query.offset, 10) || 0, 0)
const feed = await teamActivity.feedFor(req.params.slug, req.user ? req.user.id : null, { limit, offset })
if (!feed) return res.status(404).json({ message: 'Team not found' })
return res.json(feed)
} catch (err) {
return fail(res, err, 'activity')
}
}
module.exports = { listTeams, getTeam, getRoster, getActivity }

View File

@@ -1,77 +0,0 @@
// 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 { optionalAuth } = require('../../../auth/session.middleware')
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, whatever the module’s projection answers. `linked` answers whether a character has an account behind it without saying which. WHICH rows appear is the module’s audience projection; sending a session is optional and may widen it.'
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
// #swagger.security = [{}, { "cookieAuth": [] }, { "bearerAuth": [] }]
/* #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,
optionalAuth,
ctrl.getRoster,
)
// The one route in this tier that reads the caller's identity. `optionalAuth`
// serves anonymous callers rather than rejecting them, and identifies an
// authenticated one properly enough that a banned or logged-out account drops
// back to the public half of the feed at once (TEAMS.md §4.3).
teamsRouter.get(
'/:slug/activity',
// #swagger.tags = ['Public · Teams']
// #swagger.summary = 'A Team’s activity feed, filtered to what the caller may see'
// #swagger.description = 'Items are `public` or `members`. Anyone who can see the Team gets the public ones; members and forum-granted users also get the members-only ones, and the response says which via `scope` so a client can render "some items are hidden" rather than presenting a filtered feed as the whole one. Sending a session is optional.'
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Page size, max 100 (default 50).' }
// #swagger.parameters['offset'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Rows to skip (default 0).' }
// #swagger.security = [{}, { "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'One page of the feed', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicTeamActivity" } } } } */
/* #swagger.responses[404] = { description: 'No such Team, or it is hidden from this caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
siteMode,
optionalAuth,
ctrl.getActivity,
)
module.exports = teamsRouter

View File

@@ -9,7 +9,6 @@ const http = require('http')
// now because none of it reaches the loader's scan.
const botScore = require('./middleware/botScore')
const announceWorker = require('./utils/announceWorker')
const teamActivityPrune = require('./utils/teamActivityPrune')
const { ensureSchema, close } = require('./utils/db')
const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed')
const settings = require('./model/settings/settings.model')
@@ -151,11 +150,6 @@ async function start() {
// retry per leg. No-op until a news post is actually published.
announceWorker.start()
// Bound the per-Team activity feed (TEAMS.md §4.2). A feed fed by a game loop
// is the obvious unbounded-growth failure, so retention starts with the feed
// rather than after someone notices. No-op on a deployment with no Teams.
teamActivityPrune.start()
setupShutdown(server, internalServer)
}
@@ -173,7 +167,6 @@ function setupShutdown(server, internalServer) {
await moduleLifecycle.shutdown()
botScore.stopSweeper() // stop the bot-store cleanup interval
announceWorker.stop() // stop the news-announcement dispatcher poller
teamActivityPrune.stop() // stop the Team activity retention timer
server.close(() => log.info('http server closed'))
if (internalServer) internalServer.close(() => log.info('internal http server closed'))
try {

View File

@@ -1,212 +0,0 @@
// ── 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 }

View File

@@ -1,64 +0,0 @@
// ── Team activity retention worker ──────────────────────────────────────────
//
// TEAMS.md §4.2's nightly prune: an age horizon (`team_activity_retain_days`,
// default 90) and a per-Team row cap (`team_activity_row_cap`, default 2000).
// Both live in `settings`, so an operator can tighten a busy shard without a
// deploy.
//
// Same in-process shape as utils/announceWorker and middleware/botScore's
// sweeper — setInterval + unref + stop(), wired into server.js start/shutdown.
// There is no cron in this stack and adding one for a single daily DELETE would
// be a dependency to justify at every future upgrade.
//
// **The first run is delayed rather than immediate.** A prune at boot would put a
// table-wide DELETE in front of the first request on every restart, and a
// deployment that is crash-looping would run it on every loop. Five minutes in is
// past the point where a boot has either succeeded or failed.
const teamActivity = require('../model/teams/teamActivity.model')
const log = require('./logger')('teams')
// Nightly, per §4.2. Not aligned to a wall-clock hour: the work is proportional
// to what arrived rather than to when it is done, and pinning it to 03:00 would
// mean a process that restarts each afternoon never prunes at all.
const INTERVAL_MS = Number(process.env.TEAM_ACTIVITY_PRUNE_MS) || 24 * 60 * 60 * 1000
const FIRST_RUN_MS = Number(process.env.TEAM_ACTIVITY_PRUNE_DELAY_MS) || 5 * 60 * 1000
let timer = null
let firstRun = null
/** One prune. Never throws — it runs on a timer with nobody to catch it. */
async function tick() {
try {
return await teamActivity.prune()
} catch (err) {
log.error('team activity prune failed', { message: err.message })
return null
}
}
function start() {
if (timer || firstRun) return timer
firstRun = setTimeout(() => {
firstRun = null
tick()
timer = setInterval(() => { tick() }, INTERVAL_MS)
if (timer.unref) timer.unref()
}, FIRST_RUN_MS)
if (firstRun.unref) firstRun.unref()
log.info('team activity retention started', { intervalMs: INTERVAL_MS, firstRunMs: FIRST_RUN_MS })
return timer
}
function stop() {
if (firstRun) {
clearTimeout(firstRun)
firstRun = null
}
if (timer) {
clearInterval(timer)
timer = null
}
}
module.exports = { start, stop, tick, INTERVAL_MS, FIRST_RUN_MS }

File diff suppressed because it is too large Load Diff

View File

@@ -927,351 +927,6 @@ 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 },
enabled: {
type: 'boolean',
description:
'Whether this deployment has Teams at all — a provider is registered, or Teams exist from one that since went away. The `teams` nav feature flag resolves from this; false means bare core, where a Teams link would lead to a permanently empty page.',
example: true,
},
},
},
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 },
projected: {
type: 'boolean',
description:
'Whether the module applied its own audience projection to this roster. False means the module declined or does not project, and the roster was served at core’s public shape — never the full one.',
example: true,
},
},
},
PublicTeamActivityItem: {
type: 'object',
description:
'`summary` is already-rendered text supplied by whoever pushed the item; core never composes one. `kind` and `payload` are opaque to core — only the module’s `team.overview` slot renders anything richer than the text.',
properties: {
id: { type: 'integer', example: 4821 },
source: { type: 'string', description: '`core` or a module id.', example: 'uo' },
kind: { type: 'string', example: 'uo.champion.completed' },
summary: { type: 'string', example: 'Completed Champion Neira' },
visibility: { type: 'string', enum: ['public', 'members'] },
occurredAt: { type: 'string', format: 'date-time' },
payload: { type: 'object', nullable: true, additionalProperties: true },
},
},
PublicTeamActivity: {
type: 'object',
properties: {
items: { type: 'array', items: { $ref: '#/components/schemas/PublicTeamActivityItem' } },
total: { type: 'integer', description: 'Matching rows for THIS caller’s visibility, so paging is honest.', example: 137 },
limit: { type: 'integer', example: 50 },
offset: { type: 'integer', example: 0 },
scope: {
type: 'string',
enum: ['public', 'members'],
description:
'Which visibilities this caller received. `public` means members-only items were withheld — render that fact rather than presenting a filtered feed as the whole one.',
},
},
},
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

@@ -11,11 +11,9 @@
// - **enable must not touch the loader.** Disable ran the module's onShutdown;
// there is no onBoot re-dispatch, so flipping the record back would put a
// module with closed sockets and cleared timers back on the nav.
// - **uninstall's four steps have exactly one valid order**: stop, purge,
// remove the directory, remove the row. purge.sql lives inside that
// directory, so purging after the delete silently does nothing with a 200 —
// and purging before the stop drops the tables under a module that is still
// running. See the test itself for why neither is visible in a response.
// - **purge must run before the directory is removed.** purge.sql lives inside
// that directory. Reorder those two lines and the feature silently stops
// working, with a 200 and no data deleted.
//
// Point the DB at a closed port BEFORE requiring anything that builds the pool.
process.env.DB_HOST = '127.0.0.1'
@@ -335,20 +333,10 @@ test('a shutdown hook that failed is reported rather than swallowed', async () =
// ── uninstall and purge ────────────────────────────────────────────────────
test('uninstall stops the module, THEN purges, THEN removes the directory', async () => {
// Two orderings pinned by one assertion, because both are one line away from
// being wrong and neither failure is visible in the response:
//
// - purge BEFORE removeDir, or decision 5 stops working entirely — purge.sql
// is a file inside the directory being deleted, and the endpoint would
// still answer 200 having deleted nothing.
// - stop BEFORE purge, or the tables are dropped under a module that is
// still started. It keeps serving and ingesting against a schema that no
// longer exists for as long as its onShutdown takes (module-uo's uo-link
// WebSocket keeps writing shard events), and requests in flight answer 500
// where a stopped module answers 404. Nothing about the old order was
// required: purge.sql stays readable until removeDir, which is the only
// step that touches the filesystem.
test('uninstall purges BEFORE it removes the directory', async () => {
// The ordering that makes decision 5 work at all: purge.sql is a file inside
// the directory being deleted. Swap these two and the endpoint still answers
// 200 and deletes nothing.
const order = []
modules.get = async (id) => ({ id, state: 'started' })
install.isInstalled = () => true
@@ -361,31 +349,12 @@ test('uninstall stops the module, THEN purges, THEN removes the directory', asyn
const res = mockRes()
await ctrl.remove(req({ params: { id: 'uo' }, query: { purge: 'true' } }), res)
assert.deepEqual(order, ['stop', 'purge', 'removeDir', 'removeRow'])
assert.deepEqual(order, ['purge', 'stop', 'removeDir', 'removeRow'])
assert.equal(res.body.purged, 12)
assert.equal(res.body.restartRequired, true)
assert.equal(logged[0].action, 'module.purge')
})
test('a refused purge does not stop the module on its way out', async () => {
// The 400 branch below is resolved BEFORE anything is stopped. A request that
// is going to be refused must leave the module exactly as it found it —
// otherwise "you cannot delete this module's data" would silently take the
// module down as a side effect of saying no.
const order = []
modules.get = async (id) => ({ id, state: 'started' })
install.isInstalled = () => true
install.purgeFile = () => null
lifecycle.stop = async () => { order.push('stop'); return { stopped: true, error: null } }
install.removeDir = async () => { order.push('removeDir'); return true }
const res = mockRes()
await ctrl.remove(req({ params: { id: 'uo' }, query: { purge: 'true' } }), res)
assert.equal(res.statusCode, 400)
assert.deepEqual(order, [])
})
test('a plain uninstall keeps the row and does not purge', async () => {
const order = []
modules.get = async (id) => ({ id, state: 'started' })

View File

@@ -507,13 +507,9 @@ 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', 'teams', 'uploads', 'users', 'validator',
'posts', 'push', 'secretBox', 'settings', 'site', '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

@@ -1,192 +0,0 @@
// 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

@@ -1,209 +0,0 @@
// 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

@@ -1,271 +0,0 @@
// The per-Team activity feed (docs/website/TEAMS.md Part 4).
//
// The db layer is stubbed and an in-memory table stands in for `team_activity`,
// so these are assertions about the RULES: what a module is allowed to write,
// what a caller is allowed to see, and what the prune takes away. The three worth
// protecting are the ones that are easy to "simplify" into a leak:
//
// 1. a module writes only into its OWN active Teams, named by external id;
// 2. visibility defaults to `members` and is resolved from the session, never
// from a request parameter;
// 3. a hidden Team's feed does not answer a public caller.
const { test, beforeEach, afterEach } = require('node:test')
const assert = require('node:assert/strict')
const activityDb = require('../src/model/teams/teamActivity.db')
const teamsDb = require('../src/model/teams/teams.db')
const access = require('../src/model/teams/teamAccess.model')
const settings = require('../src/model/settings/settings.model')
const activity = require('../src/model/teams/teamActivity.model')
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 stub() {
store = {
rows: [],
nextId: 1,
teams: [
{ id: 1, module_id: 'uo', external_id: 'g1', slug: 'the-guild', hidden: 0, status: 'active' },
{ id: 2, module_id: 'uo', external_id: 'g2', slug: 'hidden-guild', hidden: 1, status: 'active' },
],
allowed: new Set(), // userIds with member/grant access, keyed "teamId:userId"
}
patch(teamsDb, 'findActive', async (moduleId, externalId) =>
store.teams.find((t) => t.module_id === moduleId && t.external_id === externalId && t.status === 'active'))
patch(teamsDb, 'findBySlug', async (slug) => store.teams.find((t) => t.slug === slug))
patch(access, 'forumAccess', async (teamId, userId) => ({
allowed: store.allowed.has(`${teamId}:${userId}`),
viaMembership: store.allowed.has(`${teamId}:${userId}`),
viaGrant: false,
isLeader: false,
}))
patch(activityDb, 'insert', async (item) => {
if (item.dedupeKey && store.rows.some((r) => r.team_id === item.teamId && r.dedupe_key === item.dedupeKey)) {
return false // the unique index doing its job
}
store.rows.push({
id: store.nextId++,
team_id: item.teamId,
source: item.source,
kind: item.kind,
summary: item.summary,
visibility: item.visibility,
actor_member_key: item.actorMemberKey,
actor_user_id: item.actorUserId,
payload: item.payload,
occurred_at: new Date(item.occurredAt),
dedupe_key: item.dedupeKey,
})
return true
})
patch(activityDb, 'page', async (teamId, visibilities, { limit, offset }) =>
store.rows
.filter((r) => r.team_id === teamId && visibilities.includes(r.visibility))
.sort((a, b) => b.occurred_at - a.occurred_at || b.id - a.id)
.slice(offset, offset + limit))
patch(activityDb, 'count', async (teamId, visibilities) =>
store.rows.filter((r) => r.team_id === teamId && visibilities.includes(r.visibility)).length)
patch(activityDb, 'deleteOlderThan', async (days) => {
const cutoff = Date.now() - days * 86400_000
const before = store.rows.length
store.rows = store.rows.filter((r) => r.occurred_at.getTime() >= cutoff)
return before - store.rows.length
})
patch(activityDb, 'overCap', async (cap) => {
const byTeam = new Map()
for (const r of store.rows) byTeam.set(r.team_id, (byTeam.get(r.team_id) || 0) + 1)
return [...byTeam].filter(([, n]) => n > cap).map(([team_id, n]) => ({ team_id, n }))
})
patch(activityDb, 'trimToCap', async (teamId, cap) => {
const mine = store.rows
.filter((r) => r.team_id === teamId)
.sort((a, b) => b.occurred_at - a.occurred_at || b.id - a.id)
const keep = new Set(mine.slice(0, cap).map((r) => r.id))
const before = store.rows.length
store.rows = store.rows.filter((r) => r.team_id !== teamId || keep.has(r.id))
return before - store.rows.length
})
patch(settings, 'get', async () => null) // defaults
}
const item = (extra = {}) => ({ externalId: 'g1', kind: 'uo.thing', summary: 'A thing happened', ...extra })
beforeEach(stub)
afterEach(restore)
// ── What a module may write ────────────────────────────────────────────────
test('a pushed item lands against the team its external id names', async () => {
const stored = await activity.push('uo', [item()])
assert.equal(stored, 1)
assert.equal(store.rows[0].team_id, 1)
assert.equal(store.rows[0].source, 'uo')
})
test('a module cannot write into another module\'s team', async () => {
// 'other' owns no team with external id g1, so there is nothing to resolve —
// and no integer the module could have sent instead, which is the point of
// naming Teams by external id on this path.
const stored = await activity.push('other', [item()])
assert.equal(stored, 0)
assert.equal(store.rows.length, 0)
})
test('an unknown external id is dropped rather than raised', async () => {
const stored = await activity.push('uo', [item({ externalId: 'nope' })])
assert.equal(stored, 0)
})
test('visibility defaults to members, and an unknown value does not widen it', async () => {
await activity.push('uo', [item(), item({ visibility: 'everyone' }), item({ visibility: 'public' })])
assert.deepEqual(store.rows.map((r) => r.visibility), ['members', 'members', 'public'])
})
test('an item with no kind or no summary is dropped, and the rest of the batch still lands', async () => {
const stored = await activity.push('uo', [item({ kind: '' }), item({ summary: ' ' }), item()])
assert.equal(stored, 1)
assert.equal(store.rows.length, 1)
})
test('an over-long summary is truncated rather than losing the event', async () => {
await activity.push('uo', [item({ summary: 'x'.repeat(400) })])
assert.equal(store.rows[0].summary.length, 255)
})
test('a replayed batch with dedupe keys stores each item once', async () => {
const batch = [item({ dedupeKey: 'champ:77' }), item({ dedupeKey: 'champ:78' })]
await activity.push('uo', batch)
await activity.push('uo', batch) // the sidecar reconnect backfill
assert.equal(store.rows.length, 2)
})
test('items without a dedupe key are never collapsed into each other', async () => {
await activity.push('uo', [item(), item()])
assert.equal(store.rows.length, 2)
})
test('a push is never rejected for being malformed at the top level', async () => {
assert.equal(await activity.push('uo', null), 0)
assert.equal(await activity.push('uo', []), 0)
})
// ── What a caller may see ──────────────────────────────────────────────────
test('an anonymous caller gets public items only, and is told the scope', async () => {
await activity.push('uo', [item({ visibility: 'public' }), item({ visibility: 'members' })])
const feed = await activity.feedFor('the-guild', null)
assert.equal(feed.items.length, 1)
assert.equal(feed.items[0].visibility, 'public')
assert.equal(feed.scope, 'public')
// `total` is the caller's total, not the table's — otherwise paging lies.
assert.equal(feed.total, 1)
})
test('a member sees both, via the same resolver the forum uses', async () => {
await activity.push('uo', [item({ visibility: 'public' }), item({ visibility: 'members' })])
store.allowed.add('1:7')
const feed = await activity.feedFor('the-guild', 7)
assert.equal(feed.items.length, 2)
assert.equal(feed.scope, 'members')
})
test('an authenticated non-member is exactly an anonymous caller here', async () => {
await activity.push('uo', [item({ visibility: 'members' })])
const feed = await activity.feedFor('the-guild', 99)
assert.equal(feed.items.length, 0)
assert.equal(feed.scope, 'public')
})
test('a hidden team\'s feed does not answer the public, but does answer its members', async () => {
await activity.push('uo', [item({ externalId: 'g2', visibility: 'public' })])
assert.equal(await activity.feedFor('hidden-guild', null), null)
store.allowed.add('2:7')
const feed = await activity.feedFor('hidden-guild', 7)
assert.equal(feed.items.length, 1)
})
test('an unknown slug is not found rather than empty', async () => {
assert.equal(await activity.feedFor('no-such-team', null), null)
})
test('the rendered item carries the payload and never the actor identifiers', async () => {
await activity.push('uo', [item({
visibility: 'public', payload: { serial: '0x77' }, actorMemberKey: '0x40012ab3', actorUserId: 7,
})])
const feed = await activity.feedFor('the-guild', null)
assert.deepEqual(feed.items[0].payload, { serial: '0x77' })
assert.equal('actorMemberKey' in feed.items[0], false)
assert.equal('actorUserId' in feed.items[0], false)
})
// ── Core's own items ───────────────────────────────────────────────────────
test('core writes as source=core and public', async () => {
await activity.logCore({ teamId: 1, kind: activity.CORE_KINDS.MEMBER_JOINED, summary: 'Aldric joined' })
assert.equal(store.rows[0].source, 'core')
assert.equal(store.rows[0].visibility, 'public')
})
test('core refuses an item with nothing to say', async () => {
assert.equal(await activity.logCore({ teamId: 1, kind: 'core.x' }), false)
assert.equal(store.rows.length, 0)
})
// ── Retention ──────────────────────────────────────────────────────────────
test('the prune drops rows past the age horizon', async () => {
const old = Date.now() - 100 * 86400_000
await activity.push('uo', [item({ occurredAt: old }), item()])
const res = await activity.prune()
assert.equal(res.days, activity.DEFAULT_RETAIN_DAYS)
assert.equal(res.byAge, 1)
assert.equal(store.rows.length, 1)
})
test('the prune trims a team back to the row cap, newest kept', async () => {
patch(settings, 'get', async (key) => (key === activity.CAP_KEY ? '3' : null))
const base = Date.now()
for (let i = 0; i < 6; i++) {
// eslint-disable-next-line no-await-in-loop
await activity.push('uo', [item({ summary: `event ${i}`, occurredAt: base + i * 1000 })])
}
const res = await activity.prune()
assert.equal(res.byCap, 3)
assert.deepEqual(store.rows.map((r) => r.summary), ['event 3', 'event 4', 'event 5'])
})
test('a zero or negative retention setting is rejected rather than emptying the feed', async () => {
patch(settings, 'get', async (key) => (key === activity.RETAIN_KEY ? '0' : null))
await activity.push('uo', [item()])
const res = await activity.prune()
assert.equal(res.days, activity.DEFAULT_RETAIN_DAYS)
assert.equal(store.rows.length, 1)
})
test('an unreadable settings table leaves the defaults standing', async () => {
patch(settings, 'get', async () => { throw new Error('pool down') })
const res = await activity.retentionConfig()
assert.deepEqual(res, { days: activity.DEFAULT_RETAIN_DAYS, cap: activity.DEFAULT_ROW_CAP })
})

View File

@@ -1,293 +0,0 @@
// Auto-hide and the §2.9 approval gate (docs/website/TEAMS.md §2.8–§2.9).
//
// The gate's SCOPE is what these tests pin down, and it is the thing most likely
// to be widened by accident. Three actions are gated because they publish
// untrusted game-sourced strings; everything else staff can do still applies at
// once. Gating more would make this a general staff-approval workflow, which is a
// different and much larger idea — and gating admins would wedge the
// single-admin deployments `npm run seed` creates.
const { test, beforeEach, afterEach } = require('node:test')
const assert = require('node:assert/strict')
const moderationDb = require('../src/model/teams/teamModeration.db')
const teamsDb = require('../src/model/teams/teams.db')
const activity = require('../src/model/activity/activity.model')
const reservedNames = require('../src/utils/reservedNames')
const moderation = require('../src/model/teams/teamModeration.model')
const saved = []
function patch(mod, name, fn) {
saved.push([mod, name, mod[name]])
mod[name] = fn
}
let db
let logged
const admin = { id: 1, username: 'root', role: 'admin' }
const mod = { id: 2, username: 'mod1', role: 'moderator' }
function stub() {
db = {
teams: new Map([[1, { id: 1, name: 'Admin', slug: 'admin', hidden: 1, hidden_reason: 'reserved_name' }]]),
requests: new Map(),
nextRequestId: 1,
}
logged = []
patch(teamsDb, 'findById', async (id) => db.teams.get(id))
patch(moderationDb, 'setHidden', async (id, { hidden, reason, term }) => {
const t = db.teams.get(id)
Object.assign(t, { hidden: hidden ? 1 : 0, hidden_reason: hidden ? reason : null, hidden_term: hidden ? term : null })
})
patch(moderationDb, 'markNameReviewed', async (id) => { db.teams.get(id).name_reviewed_at = 'now' })
patch(moderationDb, 'setDisplayNameOverride', async (id, value) => {
db.teams.get(id).display_name_override = value
})
patch(moderationDb, 'insertRequest', async (row) => {
const id = db.nextRequestId++
db.requests.set(id, { id, status: 'pending', ...row, payload: row.payload, team_id: row.teamId, requested_username: row.requestedUsername })
return id
})
patch(moderationDb, 'findRequest', async (id) => db.requests.get(id))
patch(moderationDb, 'decideRequest', async (id, { status, decidedUsername }) => {
const r = db.requests.get(id)
if (!r || r.status !== 'pending') return false
Object.assign(r, { status, decided_username: decidedUsername })
return true
})
patch(activity, 'log', async (entry) => { logged.push(entry) })
}
beforeEach(stub)
afterEach(() => {
while (saved.length) {
const [m, name, fn] = saved.pop()
m[name] = fn
}
})
const actions = () => logged.map((l) => l.action)
// ── Auto-hide at create ────────────────────────────────────────────────────
test('a reserved name produces the hide columns a create should carry', async () => {
patch(reservedNames, 'screen', async () => ({ reserved: true, term: 'admin' }))
assert.deepEqual(await moderation.screenForCreate('Admin'), {
hidden: true, hiddenReason: 'reserved_name', hiddenTerm: 'admin',
})
})
test('an ordinary name carries nothing', async () => {
patch(reservedNames, 'screen', async () => ({ reserved: false, term: null }))
assert.deepEqual(await moderation.screenForCreate('The Silver Hand'), { hidden: false })
})
test('a screening failure creates the team unscreened rather than aborting the reconcile', async () => {
// A deliberate trade: the re-screen on the next sync catches it, and a
// reconcile that dies halfway through is worse than a name public for one
// interval. It is also why re-screening exists rather than being create-only.
patch(reservedNames, 'screen', async () => { throw new Error('settings unavailable') })
assert.deepEqual(await moderation.screenForCreate('Admin'), { hidden: false })
})
// ── Re-screening ───────────────────────────────────────────────────────────
test('a re-screen hides a team whose name became reserved', async () => {
patch(moderationDb, 'unreviewedActive', async () => [{ id: 1, name: 'Council', hidden: 0 }])
patch(reservedNames, 'screen', async () => ({ reserved: true, term: 'Council' }))
assert.equal(await moderation.rescreen('uo'), 1)
assert.equal(db.teams.get(1).hidden, 1)
assert.equal(db.teams.get(1).hidden_term, 'Council')
})
test('a re-screen never re-hides a team staff have already ruled on', async () => {
// unreviewedActive excludes them by definition — the stamp is the mechanism,
// and without it an override would be undone on every sweep.
patch(moderationDb, 'unreviewedActive', async () => [])
patch(reservedNames, 'screen', async () => ({ reserved: true, term: 'admin' }))
assert.equal(await moderation.rescreen('uo'), 0)
})
test('a re-screen skips a team that is already hidden', async () => {
patch(moderationDb, 'unreviewedActive', async () => [{ id: 1, name: 'Admin', hidden: 1 }])
patch(reservedNames, 'screen', async () => { throw new Error('should not be screened again') })
assert.equal(await moderation.rescreen('uo'), 0)
})
test('a failing re-screen does not break the reconcile that called it', async () => {
patch(moderationDb, 'unreviewedActive', async () => { throw new Error('db down') })
assert.equal(await moderation.rescreen('uo'), 0)
})
// ── The gate: moderator asks, admin applies ────────────────────────────────
test('a moderator un-hiding files a pending request and changes nothing public', async () => {
const result = await moderation.requestOrApply({
actor: mod, teamId: 1, action: 'unhide', reason: 'legitimate guild',
})
assert.equal(result.pending, true)
assert.equal(db.teams.get(1).hidden, 1, 'nothing is published until an admin agrees')
assert.equal(db.requests.get(1).status, 'pending')
assert.deepEqual(actions(), ['team.moderation.request'])
})
test('an admin un-hiding applies at once', async () => {
const result = await moderation.requestOrApply({ actor: admin, teamId: 1, action: 'unhide' })
assert.equal(result.pending, false)
assert.equal(db.teams.get(1).hidden, 0)
assert.equal(db.teams.get(1).name_reviewed_at, 'now', 'a human has now ruled on the name')
assert.deepEqual(actions(), ['team.unhide'])
})
test('an admin approving a moderator’s request publishes it', async () => {
await moderation.requestOrApply({ actor: mod, teamId: 1, action: 'unhide', reason: 'legit' })
assert.equal(db.teams.get(1).hidden, 1)
const result = await moderation.decide({ actor: admin, requestId: 1, status: 'approved' })
assert.equal(result.applied, true)
assert.equal(db.teams.get(1).hidden, 0)
assert.deepEqual(actions(), ['team.moderation.request', 'team.unhide', 'team.moderation.approved'])
})
test('a rejected request changes nothing but is kept', async () => {
await moderation.requestOrApply({ actor: mod, teamId: 1, action: 'unhide' })
const result = await moderation.decide({ actor: admin, requestId: 1, status: 'rejected', note: 'no' })
assert.equal(result.applied, false)
assert.equal(db.teams.get(1).hidden, 1)
assert.equal(db.requests.get(1).status, 'rejected', 'the record of a refusal is the part worth having')
assert.equal(actions().includes('team.unhide'), false)
})
test('a moderator may not decide a request', async () => {
await moderation.requestOrApply({ actor: mod, teamId: 1, action: 'unhide' })
const result = await moderation.decide({ actor: mod, requestId: 1, status: 'approved' })
assert.equal(result.ok, false)
assert.equal(result.status, 403)
assert.equal(db.teams.get(1).hidden, 1)
})
test('a request already decided cannot be decided again', async () => {
await moderation.requestOrApply({ actor: mod, teamId: 1, action: 'unhide' })
await moderation.decide({ actor: admin, requestId: 1, status: 'approved' })
const second = await moderation.decide({ actor: admin, requestId: 1, status: 'rejected' })
assert.equal(second.ok, false)
assert.equal(second.status, 409)
assert.equal(db.teams.get(1).hidden, 0, 'the first decision stands')
})
test('two admins deciding at once — only one applies', async () => {
// The row moves out of `pending` under a guard, and the effect follows only if
// it actually moved. Without that, both would apply the action and the second
// would overwrite the record of who decided it.
await moderation.requestOrApply({ actor: mod, teamId: 1, action: 'unhide' })
let applied = 0
patch(moderationDb, 'setHidden', async () => { applied += 1 })
const [a, b] = await Promise.all([
moderation.decide({ actor: admin, requestId: 1, status: 'approved' }),
moderation.decide({ actor: { ...admin, id: 3, username: 'root2' }, requestId: 1, status: 'approved' }),
])
assert.equal([a.ok, b.ok].filter(Boolean).length, 1)
assert.equal(applied, 1)
})
test('an unknown request and an unknown team are refused, not guessed at', async () => {
assert.equal((await moderation.decide({ actor: admin, requestId: 99, status: 'approved' })).status, 404)
assert.equal((await moderation.requestOrApply({ actor: admin, teamId: 99, action: 'unhide' })).status, 404)
})
test('an invalid decision status is refused', async () => {
await moderation.requestOrApply({ actor: mod, teamId: 1, action: 'unhide' })
assert.equal((await moderation.decide({ actor: admin, requestId: 1, status: 'maybe' })).status, 400)
})
// ── The display-name override, through the same gate ───────────────────────
test('a display name set by an admin applies; by a moderator it waits', async () => {
await moderation.requestOrApply({
actor: admin, teamId: 1, action: 'display_name_override', payload: { displayName: 'The Old Guard' },
})
assert.equal(db.teams.get(1).display_name_override, 'The Old Guard')
db.teams.get(1).display_name_override = null
await moderation.requestOrApply({
actor: mod, teamId: 1, action: 'display_name_override', payload: { displayName: 'Sneaky' },
})
assert.equal(db.teams.get(1).display_name_override, null, 'free text into a public surface waits for an admin')
})
test('an approved display-name request carries its payload through', async () => {
await moderation.requestOrApply({
actor: mod, teamId: 1, action: 'display_name_override', payload: { displayName: 'The Old Guard' },
})
await moderation.decide({ actor: admin, requestId: 1, status: 'approved' })
assert.equal(db.teams.get(1).display_name_override, 'The Old Guard')
})
test('a payload stored as a JSON string is parsed on approval', async () => {
// The driver hands JSON columns back parsed on some versions and as a string on
// others; an approval that silently applied `undefined` would be a data loss
// that only shows up on one of them.
await moderation.requestOrApply({
actor: mod, teamId: 1, action: 'display_name_override', payload: { displayName: 'Kept' },
})
db.requests.get(1).payload = JSON.stringify({ displayName: 'Kept' })
await moderation.decide({ actor: admin, requestId: 1, status: 'approved' })
assert.equal(db.teams.get(1).display_name_override, 'Kept')
})
test('clearing a display name is gated too', async () => {
db.teams.get(1).display_name_override = 'Something'
await moderation.requestOrApply({ actor: mod, teamId: 1, action: 'clear_display_name_override' })
assert.equal(db.teams.get(1).display_name_override, 'Something')
await moderation.decide({ actor: admin, requestId: 1, status: 'approved' })
assert.equal(db.teams.get(1).display_name_override, null)
})
// ── Hiding is NOT gated ────────────────────────────────────────────────────
test('a moderator may hide immediately — suppression is always safe', async () => {
db.teams.get(1).hidden = 0
const result = await moderation.hide({ actor: mod, teamId: 1, reason: 'impersonation' })
assert.equal(result.ok, true)
assert.equal(db.teams.get(1).hidden, 1)
assert.equal(db.teams.get(1).hidden_reason, 'staff')
assert.deepEqual(actions(), ['team.hide'])
assert.equal(db.requests.size, 0, 'withdrawing untrusted data must not wait for a second pair of eyes')
})
// ── The gate's scope ───────────────────────────────────────────────────────
test('exactly three actions are gated', () => {
assert.deepEqual(moderation.GATED_ACTIONS, ['unhide', 'display_name_override', 'clear_display_name_override'])
})
test('an action outside the three is rejected rather than quietly gated', async () => {
await assert.rejects(
() => moderation.requestOrApply({ actor: mod, teamId: 1, action: 'archive' }),
/not a gated action/,
)
})
test('every transition writes the audit log', async () => {
await moderation.requestOrApply({ actor: mod, teamId: 1, action: 'unhide', reason: 'legit' })
await moderation.decide({ actor: admin, requestId: 1, status: 'approved', note: 'checked' })
assert.equal(logged.length, 3)
assert.match(logged[0].detail, /mod1 \(#2\) requested "unhide" on team "Admin" \(#1\): "legit"/)
assert.match(logged[2].detail, /root \(#1\) approved request #1/)
assert.match(logged[2].detail, /asked by mod1/)
})
test('the audit trail survives the requester’s account being deleted', async () => {
await moderation.requestOrApply({ actor: mod, teamId: 1, action: 'unhide' })
// §2.10: requested_by goes SET NULL and the username snapshot is what keeps the
// record readable.
db.requests.get(1).requested_username = null
await moderation.decide({ actor: admin, requestId: 1, status: 'rejected' })
assert.match(logged[logged.length - 1].detail, /asked by a deleted user/)
})

View File

@@ -1,376 +0,0 @@
// 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)
})
// ── projectRoster: the optional fourth member (§3.3) ───────────────────────
//
// The one Team call where a refusal must NOT be treated as staleness. Every test
// below exists because the obvious implementation — reuse `call()` and serve the
// roster when it fails — silently publishes the rows the rungs exist to withhold.
const rows = [{ member_key: '0x1' }, { member_key: '0x2' }]
test('projectRoster is optional: a provider without it registers fine', () => {
const api = registries.stage('uo')
assert.doesNotThrow(() => api.registerTeamProvider(ok()))
})
test('a non-function projectRoster is rejected at registration, not at call time', () => {
const api = registries.stage('uo')
assert.throws(
() => api.registerTeamProvider({ ...ok(), projectRoster: 'yes please' }),
/projectRoster must be a function/,
)
})
test('an unregistered method cannot ride along into the provider core calls', () => {
register('uo', { ...ok(), somethingElse: async () => 'hi' })
assert.equal(registries.registeredTeamProvider().somethingElse, undefined)
})
test('no provider at all is projects:false — nothing is being withheld', async () => {
const answer = await teamProvider.projectRoster('g1', rows, null)
assert.equal(answer.ok, false)
assert.equal(answer.projects, false)
})
test('a provider that does not project is projects:false, not a failure to fear', async () => {
register('uo', ok())
const answer = await teamProvider.projectRoster('g1', rows, null)
assert.equal(answer.ok, false)
assert.equal(answer.projects, false)
})
test('a provider that HAS projectRoster and refuses is projects:true — the caller must fail closed', async () => {
register('uo', { ...ok(), projectRoster: async () => ({ ok: false, reason: 'atlas not loaded' }) })
const answer = await teamProvider.projectRoster('g1', rows, null)
assert.equal(answer.ok, false)
assert.equal(answer.projects, true)
assert.equal(answer.reason, 'atlas not loaded')
})
test('a projectRoster that throws is projects:true as well — a bug is not permission', async () => {
register('uo', { ...ok(), projectRoster: async () => { throw new Error('boom') } })
const answer = await teamProvider.projectRoster('g1', rows, null)
assert.equal(answer.projects, true)
})
test('the module receives the rows and the viewer, and answers with member keys', async () => {
let seen
register('uo', {
...ok(),
projectRoster: async (externalId, members, viewer) => {
seen = { externalId, members, viewer }
return { ok: true, members: ['0x2'] }
},
})
const answer = await teamProvider.projectRoster('g1', rows, { userId: 7, role: 'player' })
assert.deepEqual(seen.members, rows)
assert.deepEqual(seen.viewer, { userId: 7, role: 'player' })
assert.equal(seen.externalId, 'g1')
assert.deepEqual(answer.members, ['0x2'])
})
test('a malformed key list is a refusal, so the caller fails closed rather than serving garbage', async () => {
for (const bad of [{ ok: true }, { ok: true, members: ['ok', ''] }, { ok: true, members: 'all' }]) {
// eslint-disable-next-line no-await-in-loop
register('uo', { ...ok(), projectRoster: async () => bad })
// eslint-disable-next-line no-await-in-loop
const answer = await teamProvider.projectRoster('g1', rows, null)
assert.equal(answer.ok, false, JSON.stringify(bad))
assert.equal(answer.projects, true)
registries._reset()
}
})
test('duplicate keys are collapsed', async () => {
register('uo', { ...ok(), projectRoster: async () => ({ ok: true, members: ['0x1', '0x1', '0x2'] }) })
const answer = await teamProvider.projectRoster('g1', rows, null)
assert.deepEqual(answer.members, ['0x1', '0x2'])
})

View File

@@ -1,122 +0,0 @@
// The roster read and its audience projection (docs/website/TEAMS.md §3.2, §3.3).
//
// Two questions meet here and the file exists to keep them apart:
//
// WHICH ROWS is the module's — it owns the visibility framework and its rung
// configuration, and core does not know what a rung is.
// WHAT A ROW is core's — the member key and the user id are never published,
// LOOKS LIKE whatever the module answers.
//
// The dangerous simplification is to let the module return rows instead of keys:
// core's field guarantee would then rest on every module's good behaviour rather
// than on core, and one module re-adding a `userId` would publish site accounts
// against in-game characters on a public page.
const { test, beforeEach, afterEach } = require('node:test')
const assert = require('node:assert/strict')
const teamsDb = require('../src/model/teams/teams.db')
const access = require('../src/model/teams/teamAccess.model')
const teamProvider = require('../src/model/teams/teamProvider')
const teamSync = require('../src/model/teams/teamSync.model')
const teams = require('../src/model/teams/teams.model')
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()
}
const ROWS = [
{ member_key: '0x1', display_name: 'Aldric', user_id: 7, is_leader: 1, rank_label: 'Leader', online: 1 },
{ member_key: '0x2', display_name: 'Brenna', user_id: null, is_leader: 0, rank_label: null, online: 0 },
{ member_key: '0x3', display_name: 'Cadfael', user_id: 9, is_leader: 0, rank_label: null, online: 0 },
]
beforeEach(() => {
patch(teamsDb, 'findBySlug', async (slug) =>
(slug === 'the-guild'
? { id: 1, external_id: 'g1', slug, hidden: 0, status: 'active', roster_synced_at: null }
: undefined))
patch(access, 'rosterWithOverrides', async () => ROWS.map((r) => ({ ...r })))
// syncStatus() reads sync state and the poll interval; neither is what this
// file is about, and both would otherwise reach the pool.
patch(teamProvider, 'providerModuleId', () => null)
patch(teamSync, 'intervalSeconds', async () => 900)
})
afterEach(restore)
test('with no module projecting, the whole roster is served at core\'s public shape', async () => {
patch(teamProvider, 'projectRoster', async () => ({ ok: false, projects: false, reason: 'no provider' }))
const roster = await teams.rosterPublic('the-guild', null)
assert.equal(roster.members.length, 3)
assert.equal(roster.projected, false)
assert.equal(roster.projectionUnavailable, undefined, 'nothing was withheld, so nothing to report')
})
test('the module chooses which rows a viewer sees', async () => {
patch(teamProvider, 'projectRoster', async () => ({ ok: true, members: ['0x2'] }))
const roster = await teams.rosterPublic('the-guild', null)
assert.deepEqual(roster.members.map((m) => m.displayName), ['Brenna'])
assert.equal(roster.projected, true)
})
test('a module that projects but cannot answer withholds the roster — it does not serve it', async () => {
// The whole point. "Leave it alone" is right for a roster SYNC and wrong for a
// visibility question: it would publish exactly what the rungs withhold.
patch(teamProvider, 'projectRoster', async () => ({ ok: false, projects: true, reason: 'sidecar down' }))
const roster = await teams.rosterPublic('the-guild', null)
assert.deepEqual(roster.members, [])
assert.equal(roster.projected, false)
assert.equal(roster.projectionUnavailable, true, 'an empty roster must be distinguishable from a silent one')
})
test('the module cannot widen the published fields, only narrow the rows', async () => {
// A module answering with keys it was given still yields core's shape. There is
// no answer it can give that puts a member key or a user id on a public page.
patch(teamProvider, 'projectRoster', async () => ({ ok: true, members: ['0x1', '0x2', '0x3'] }))
const roster = await teams.rosterPublic('the-guild', null)
for (const member of roster.members) {
assert.deepEqual(
Object.keys(member).sort(),
['displayName', 'isLeader', 'linked', 'online', 'rankLabel'],
'the public member shape is core\'s and is closed',
)
}
assert.deepEqual(roster.members.map((m) => m.linked), [true, false, true])
})
test('a key the module invents matches nothing rather than adding a row', async () => {
patch(teamProvider, 'projectRoster', async () => ({ ok: true, members: ['0x1', '0xNOPE'] }))
const roster = await teams.rosterPublic('the-guild', null)
assert.equal(roster.members.length, 1)
})
test('the viewer is described to the module, not handed over', async () => {
let seen
patch(teamProvider, 'projectRoster', async (externalId, members, viewer) => {
seen = viewer
return { ok: true, members: members.map((m) => m.member_key) }
})
await teams.rosterPublic('the-guild', { userId: 7, role: 'player' })
assert.deepEqual(seen, { userId: 7, role: 'player' })
})
test('an unknown slug is not found, and the module is never consulted about it', async () => {
let called = false
patch(teamProvider, 'projectRoster', async () => { called = true; return { ok: true, members: [] } })
assert.equal(await teams.rosterPublic('no-such-team', null), null)
assert.equal(called, false)
})
test('a hidden team\'s roster does not answer publicly at all', async () => {
patch(teamsDb, 'findBySlug', async () => ({ id: 1, external_id: 'g1', slug: 'x', hidden: 1, status: 'active' }))
patch(teamProvider, 'projectRoster', async () => ({ ok: true, members: ['0x1'] }))
assert.equal(await teams.rosterPublic('x', null), null)
})

View File

@@ -1,304 +0,0 @@
// 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

@@ -1,977 +0,0 @@
// 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 activity = require('../src/model/teams/teamActivity.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))
// The sync reads the full rows, not just the keys: the activity feed needs each
// changing member's display name and PRIOR is_leader, both of which the upsert
// is about to overwrite. Stubbing this is not optional — an unstubbed seam here
// reaches the real pool, and the symptom is the suite hanging on dead-pool
// retries rather than failing (see test/_setup.js).
patch(teamsDb, 'membersByTeam', async (teamId, { includeDeparted = false } = {}) =>
[...membersOf(teamId).values()]
.filter((m) => includeDeparted || m.status === 'active')
.map((m) => ({ ...m })))
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)
// The activity feed is its own unit (teamActivity.test.js); here it is captured
// so the reconciler's side of §4.2 can be asserted without a database. Stubbing
// the MODEL rather than the db layer keeps these tests about which items the
// sync decides to emit, which is the reconciler's half of the contract.
store.activity = []
patch(activity, 'logCore', async (item) => {
store.activity.push(item)
return true
})
}
// A provider whose answers the test controls. Defaults are authoritative and
// well-formed, so each test only states the part it is about.
function provide(overrides = {}) {
const provider = {
getTeams: async () => ({ ok: true, teams: [] }),
getTeamMembers: async () => ({ ok: true, members: [] }),
getTeamLeaders: async () => ({ ok: true, leaders: [] }),
...overrides,
}
const api = registries.stage('uo')
api.registerTeamProvider(provider)
registries.apply(api.staged)
return provider
}
const team = (externalId, name, extra = {}) => ({ externalId, name, abbr: null, meta: null, ...extra })
const member = (memberKey, extra = {}) => ({
memberKey, displayName: memberKey, rankLabel: null, leader: false, online: false, userId: null, ...extra,
})
const activeTeams = () => store.teams.filter((t) => t.status === 'active')
const activeMembers = (teamId) => [...membersOf(teamId).values()].filter((m) => m.status === 'active')
beforeEach(() => {
store = freshStore()
registries._reset()
teamSync._reset()
stubDb()
// The settings read is the only other database touch on this path.
patch(settings, 'get', async () => null)
})
afterEach(() => {
teamSync._reset()
registries._reset()
restore()
})
// ── Gate 1: a failed getTeams() touches nothing ────────────────────────────
test('gate 1 — a provider that cannot answer leaves every row untouched', async () => {
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'The Silver Hand')] }) })
await teamSync.reconcileNow('setup')
// Compared as JSON on both sides: the rows carry Date objects, and a snapshot
// taken through JSON would otherwise "differ" from the live rows purely by
// having stringified them.
const before = JSON.stringify(store.teams)
assert.equal(store.teams.length, 1)
registries._reset()
provide({ getTeams: async () => ({ ok: false, reason: 'sidecar unreachable' }) })
const result = await teamSync.reconcileNow('test')
assert.equal(result.ok, false)
assert.equal(JSON.stringify(store.teams), before, 'not a single row may change')
assert.equal(store.sync.get('uo').consecutive_failures, 1)
assert.equal(store.sync.get('uo').last_error, 'sidecar unreachable')
})
test('gate 1 — a hung or throwing provider is the same refusal, not an empty list', async () => {
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) })
await teamSync.reconcileNow('setup')
registries._reset()
provide({ getTeams: async () => { throw new Error('EPIPE') } })
await teamSync.reconcileNow('test')
assert.equal(activeTeams().length, 1, 'a thrown error must never read as "no teams"')
})
test('failures accumulate and a success clears them', async () => {
provide({ getTeams: async () => ({ ok: false, reason: 'down' }) })
await teamSync.reconcileNow('a')
await teamSync.reconcileNow('b')
assert.equal(store.sync.get('uo').consecutive_failures, 2)
registries._reset()
provide()
await teamSync.reconcileNow('c')
assert.equal(store.sync.get('uo').consecutive_failures, 0)
assert.equal(store.sync.get('uo').last_error, null)
})
// ── Gate 2: an authoritative empty list is quarantined ─────────────────────
test('gate 2 — the first empty answer archives nothing', async () => {
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A'), team('g2', 'B')] }) })
await teamSync.reconcileNow('setup')
assert.equal(activeTeams().length, 2)
registries._reset()
provide({ getTeams: async () => ({ ok: true, teams: [] }) })
const result = await teamSync.reconcileNow('test')
assert.equal(result.quarantined, true)
assert.equal(activeTeams().length, 2, 'a cold start must not empty the site')
assert.ok(store.sync.get('uo').pending_empty_since, 'the answer is remembered')
})
test('gate 2 — a second empty answer, an interval later, is applied', async () => {
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) })
await teamSync.reconcileNow('setup')
registries._reset()
provide({ getTeams: async () => ({ ok: true, teams: [] }) })
await teamSync.reconcileNow('first empty')
// Age the quarantine past one full interval.
store.sync.get('uo').pending_empty_since = new Date(Date.now() - (teamSync.DEFAULT_INTERVAL_S + 1) * 1000)
const result = await teamSync.reconcileNow('second empty')
assert.equal(result.archived, 1, 'every team on the shard really did disband')
assert.equal(activeTeams().length, 0)
assert.equal(store.teams[0].archived_reason, 'disbanded')
})
test('gate 2 — a second empty answer TOO SOON is still quarantined', async () => {
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) })
await teamSync.reconcileNow('setup')
registries._reset()
provide({ getTeams: async () => ({ ok: true, teams: [] }) })
await teamSync.reconcileNow('first')
const result = await teamSync.reconcileNow('second, immediately')
assert.equal(result.quarantined, true, 'two answers a second apart are one cold start, not two')
assert.equal(activeTeams().length, 1)
})
test('gate 2 — any non-empty answer clears the quarantine', async () => {
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) })
await teamSync.reconcileNow('setup')
registries._reset()
provide({ getTeams: async () => ({ ok: true, teams: [] }) })
await teamSync.reconcileNow('empty')
assert.ok(store.sync.get('uo').pending_empty_since)
registries._reset()
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) })
await teamSync.reconcileNow('recovered')
assert.equal(store.sync.get('uo').pending_empty_since, null)
})
test('gate 2 — an empty list with nothing held is not a quarantine, just nothing to do', async () => {
provide({ getTeams: async () => ({ ok: true, teams: [] }) })
const result = await teamSync.reconcileNow('test')
assert.equal(result.ok, true)
assert.notEqual(result.quarantined, true)
})
test('an incomplete answer never removes, so an empty partial list is harmless', async () => {
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) })
await teamSync.reconcileNow('setup')
registries._reset()
provide({ getTeams: async () => ({ ok: true, complete: false, teams: [] }) })
const result = await teamSync.reconcileNow('partial')
assert.equal(result.archived, 0)
assert.equal(activeTeams().length, 1)
assert.ok(!store.sync.get('uo').pending_empty_since, 'no quarantine needed — nothing was at risk')
})
// ── Gate 3: one Team's unanswerable roster ─────────────────────────────────
test('gate 3 — a refused roster leaves that team alone and the others sync', async () => {
provide({
getTeams: async () => ({ ok: true, teams: [team('g1', 'A'), team('g2', 'B')] }),
getTeamMembers: async (id) => (id === 'g1'
? { ok: true, members: [member('0x1'), member('0x2')] }
: { ok: true, members: [member('0x9')] }),
})
await teamSync.reconcileNow('setup')
assert.equal(activeMembers(1).length, 2)
assert.equal(activeMembers(2).length, 1)
registries._reset()
provide({
getTeams: async () => ({ ok: true, teams: [team('g1', 'A'), team('g2', 'B')] }),
getTeamMembers: async (id) => (id === 'g1'
? { ok: false, reason: 'roster unavailable' }
: { ok: true, members: [member('0x9'), member('0xA')] }),
})
const result = await teamSync.reconcileNow('test')
assert.equal(activeMembers(1).length, 2, "g1's roster is untouched, not emptied")
assert.equal(activeMembers(2).length, 2, "g2 syncs normally — one team's problem is its own")
assert.equal(result.rosters, 1, 'only one roster was applied')
})
test('gate 3 — a refused roster does not bump that team’s freshness stamp', async () => {
provide({
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
getTeamMembers: async () => ({ ok: true, members: [member('0x1')] }),
})
await teamSync.reconcileNow('setup')
const syncedAt = store.teams[0].roster_synced_at
assert.ok(syncedAt)
registries._reset()
provide({
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
getTeamMembers: async () => ({ ok: false, reason: 'nope' }),
})
await teamSync.reconcileNow('test')
assert.equal(store.teams[0].roster_synced_at, syncedAt, 'stale must show as stale, not as just-synced')
})
test('leadership is a separate answer — a refused one does not demote anybody', async () => {
provide({
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
getTeamMembers: async () => ({ ok: true, members: [member('0x1'), member('0x2')] }),
getTeamLeaders: async () => ({ ok: true, leaders: ['0x1'] }),
})
await teamSync.reconcileNow('setup')
assert.equal(membersOf(1).get('0x1').is_leader, 1)
registries._reset()
provide({
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
getTeamMembers: async () => ({ ok: true, members: [member('0x1'), member('0x2')] }),
getTeamLeaders: async () => ({ ok: false, reason: 'cannot say' }),
})
await teamSync.reconcileNow('test')
assert.equal(membersOf(1).get('0x1').is_leader, 1, 'an unanswerable question is not the answer "nobody"')
})
test('the roster seeds is_leader on a new row but never overwrites it afterwards', async () => {
// Two writers for one column is how a refused leadership answer becomes a
// silent demotion: the roster would write `leader: false` before the
// authoritative call was even made. Seeding on insert keeps a Team from being
// leaderless while getTeamLeaders() is failing.
provide({
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
getTeamMembers: async () => ({ ok: true, members: [member('0x1', { leader: true })] }),
getTeamLeaders: async () => ({ ok: false, reason: 'cannot say' }),
})
await teamSync.reconcileNow('first sync, leadership unanswerable')
assert.equal(membersOf(1).get('0x1').is_leader, 1, 'seeded from the roster rather than left blank')
registries._reset()
provide({
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
getTeamMembers: async () => ({ ok: true, members: [member('0x1', { leader: false })] }),
getTeamLeaders: async () => ({ ok: true, leaders: ['0x1'] }),
})
await teamSync.reconcileNow('roster disagrees with the authority')
assert.equal(membersOf(1).get('0x1').is_leader, 1, 'getTeamLeaders() is path 2, and the roster is not')
})
// ── Gate 4: an authoritative empty roster ──────────────────────────────────
test('gate 4 — the first empty roster departs nobody', async () => {
let members = [member('0x1'), member('0x2')]
provide({
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
getTeamMembers: async () => ({ ok: true, members }),
})
await teamSync.reconcileNow('setup')
assert.equal(activeMembers(1).length, 2)
members = []
await teamSync.reconcileNow('empty roster')
assert.equal(activeMembers(1).length, 2, 'a cold cache must not empty a roster')
assert.ok(store.teams[0].members_empty_since)
})
test('gate 4 — a second empty roster is applied', async () => {
let members = [member('0x1'), member('0x2')]
provide({
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
getTeamMembers: async () => ({ ok: true, members }),
})
await teamSync.reconcileNow('setup')
members = []
await teamSync.reconcileNow('first empty')
await teamSync.reconcileNow('second empty')
assert.equal(activeMembers(1).length, 0, 'the guild really was emptied')
assert.equal(store.teams[0].member_count, 0)
})
test('gate 4 — a non-empty roster clears the quarantine', async () => {
let members = [member('0x1')]
provide({
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
getTeamMembers: async () => ({ ok: true, members }),
})
await teamSync.reconcileNow('setup')
members = []
await teamSync.reconcileNow('empty')
assert.ok(store.teams[0].members_empty_since)
members = [member('0x1')]
await teamSync.reconcileNow('recovered')
assert.equal(store.teams[0].members_empty_since, null)
})
test('gate 4 — a team that never had members takes an empty roster at once', async () => {
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) })
const result = await teamSync.reconcileNow('test')
assert.equal(result.rosters, 1, 'nothing is at risk, so nothing is quarantined')
assert.equal(store.teams[0].members_empty_since, null)
})
// ── Ordinary syncing ───────────────────────────────────────────────────────
test('a new team is created with a slug, and its roster and counts follow', async () => {
provide({
getTeams: async () => ({ ok: true, teams: [team('g1', 'The Silver Hand', { abbr: 'TSH' })] }),
getTeamMembers: async () => ({
ok: true,
members: [member('0x1', { userId: 7, online: true }), member('0x2')],
}),
getTeamLeaders: async () => ({ ok: true, leaders: ['0x1'] }),
})
const result = await teamSync.reconcileNow('test')
assert.equal(result.created, 1)
const row = store.teams[0]
assert.equal(row.slug, 'the-silver-hand')
assert.equal(row.member_count, 2)
assert.equal(row.linked_count, 1)
assert.equal(row.online_count, 1)
assert.equal(membersOf(1).get('0x1').is_leader, 1)
})
test('a member who disappears from a complete roster is departed, not deleted', async () => {
let members = [member('0x1'), member('0x2')]
provide({
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
getTeamMembers: async () => ({ ok: true, members }),
})
await teamSync.reconcileNow('setup')
members = [member('0x1')]
await teamSync.reconcileNow('test')
assert.equal(membersOf(1).get('0x2').status, 'departed', 'the row survives so history and rejoins do')
assert.equal(activeMembers(1).length, 1)
})
test('a rejoining member revives their row and keeps their first_seen_at', async () => {
let members = [member('0x1'), member('0x2')]
provide({
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
getTeamMembers: async () => ({ ok: true, members }),
})
await teamSync.reconcileNow('setup')
members = [member('0x1')]
await teamSync.reconcileNow('leaves')
members = [member('0x1'), member('0x2')]
await teamSync.reconcileNow('returns')
assert.equal(membersOf(1).get('0x2').status, 'active')
assert.equal(membersOf(1).get('0x2').first_seen_at, 'first', 'a rejoin is a revived row, not a second one')
})
test('an incomplete roster adds and updates but removes nothing', async () => {
provide({
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
getTeamMembers: async () => ({ ok: true, members: [member('0x1'), member('0x2')] }),
})
await teamSync.reconcileNow('setup')
registries._reset()
provide({
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
getTeamMembers: async () => ({ ok: true, complete: false, members: [member('0x3')] }),
})
await teamSync.reconcileNow('partial')
assert.equal(activeMembers(1).length, 3, 'a partial answer is not a claim about who is absent')
})
test('a team absent from a complete list is archived as disbanded', async () => {
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A'), team('g2', 'B')] }) })
await teamSync.reconcileNow('setup')
registries._reset()
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) })
await teamSync.reconcileNow('test')
assert.equal(activeTeams().length, 1)
const archived = store.teams.find((t) => t.external_id === 'g2')
assert.equal(archived.status, 'archived')
assert.equal(archived.archived_reason, 'disbanded')
})
// ── The rename rule (§2.2) ─────────────────────────────────────────────────
test('a renamed team is archived and succeeded, never edited in place', async () => {
provide({
getTeams: async () => ({ ok: true, teams: [team('g1', 'The Silver Hand')] }),
getTeamMembers: async () => ({ ok: true, members: [member('0x1')] }),
})
await teamSync.reconcileNow('setup')
registries._reset()
provide({
getTeams: async () => ({ ok: true, teams: [team('g1', 'The Golden Hand')] }),
getTeamMembers: async () => ({ ok: true, members: [member('0x1')] }),
})
const result = await teamSync.reconcileNow('rename')
assert.equal(result.renamed, 1)
const [old_, next] = store.teams
assert.equal(old_.name, 'The Silver Hand', 'the name is immutable for the life of the row')
assert.equal(old_.status, 'archived')
assert.equal(old_.archived_reason, 'renamed')
assert.equal(old_.succeeded_by, next.id, 'the old slug can explain itself instead of 404ing')
assert.equal(next.name, 'The Golden Hand')
assert.equal(next.slug, 'the-golden-hand')
assert.equal(next.status, 'active')
})
test('a rename back to a previous name does not reuse the retired slug', async () => {
const names = ['Alpha', 'Beta', 'Alpha']
let i = 0
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', names[i])] }) })
await teamSync.reconcileNow('a')
i = 1
await teamSync.reconcileNow('b')
i = 2
await teamSync.reconcileNow('c')
const slugs = store.teams.map((t) => t.slug)
assert.deepEqual(slugs, ['alpha', 'beta', 'alpha-2'])
assert.equal(new Set(slugs).size, 3, 'an archived team stays readable at its own address')
})
test('two teams with the same name get distinct slugs', async () => {
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'Guard'), team('g2', 'Guard')] }) })
await teamSync.reconcileNow('test')
assert.deepEqual(store.teams.map((t) => t.slug), ['guard', 'guard-2'])
})
test('a name with nothing URL-safe in it still gets an address', async () => {
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', '★☆★')] }) })
await teamSync.reconcileNow('test')
assert.equal(store.teams[0].slug, 'team')
assert.equal(store.teams[0].name, '★☆★', 'the identity keeps what the player typed')
})
// ── Screening is on the create path, and on every run ──────────────────────
test('every newly created team has its name screened', async () => {
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'Admin'), team('g2', 'The Silver Hand')] }) })
await teamSync.reconcileNow('test')
assert.deepEqual(store.screened, ['Admin', 'The Silver Hand'])
})
test('a renamed team is screened again under its new name', async () => {
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'Ordinary')] }) })
await teamSync.reconcileNow('setup')
registries._reset()
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'Admin')] }) })
await teamSync.reconcileNow('rename')
assert.deepEqual(store.screened, ['Ordinary', 'Admin'], 'a rename is a create, so it screens')
})
test('a hidden team is still created and still syncs its roster', async () => {
// Hide, never reject: the Team works completely for its own members. The people
// in it are not being punished for a name their leader chose.
patch(moderation, 'screenForCreate', async () => ({
hidden: true, hiddenReason: 'reserved_name', hiddenTerm: 'admin',
}))
provide({
getTeams: async () => ({ ok: true, teams: [team('g1', 'Admin')] }),
getTeamMembers: async () => ({ ok: true, members: [member('0x1'), member('0x2')] }),
})
await teamSync.reconcileNow('test')
assert.equal(store.teams[0].hidden, 1)
assert.equal(store.teams[0].hidden_term, 'admin')
assert.equal(activeMembers(1).length, 2, 'suppression is a public-surface rule, not a shutdown')
assert.equal(store.teams[0].member_count, 2)
})
test('a successful run re-screens the names no human has ruled on', async () => {
let called = 0
patch(moderation, 'rescreen', async () => { called += 1; return 0 })
provide()
await teamSync.reconcileNow('test')
assert.equal(called, 1)
})
test('a refused run does not re-screen — it does nothing at all', async () => {
let called = 0
patch(moderation, 'rescreen', async () => { called += 1; return 0 })
provide({ getTeams: async () => ({ ok: false, reason: 'down' }) })
await teamSync.reconcileNow('test')
assert.equal(called, 0)
})
// ── Events (§2.3) ──────────────────────────────────────────────────────────
test('an unknown event kind is rejected', async () => {
provide()
await assert.rejects(() => teamSync.publish({ kind: 'team.exploded', externalId: 'g1' }), /unknown event kind/)
})
test('an event with no externalId is rejected', async () => {
provide()
await assert.rejects(() => teamSync.publish({ kind: 'team.member.added' }), /no externalId/)
})
test('team.disbanded never archives — it asks for a reconciliation', async () => {
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) })
await teamSync.reconcileNow('setup')
await teamSync.publish({ kind: 'team.disbanded', externalId: 'g1' })
assert.equal(activeTeams().length, 1, 'destruction is never driven by a delta that may be a repeat')
})
test('team.created does not invent a team', async () => {
provide()
await teamSync.publish({ kind: 'team.created', externalId: 'brand-new' })
assert.equal(store.teams.length, 0, 'a team built from a delta has no name, roster or leaders')
})
test('a member delta applies at once for a known team and updates the counts', async () => {
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) })
await teamSync.reconcileNow('setup')
await teamSync.publish({
kind: 'team.member.added', externalId: 'g1', memberKey: '0x5', displayName: 'Ada', userId: 3,
})
assert.equal(activeMembers(1).length, 1)
assert.equal(membersOf(1).get('0x5').display_name, 'Ada')
assert.equal(store.teams[0].member_count, 1)
assert.equal(store.teams[0].linked_count, 1)
await teamSync.publish({ kind: 'team.member.removed', externalId: 'g1', memberKey: '0x5' })
assert.equal(activeMembers(1).length, 0)
assert.equal(store.teams[0].member_count, 0)
})
test('a leadership delta writes is_leader and nothing else', async () => {
provide({
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
getTeamMembers: async () => ({ ok: true, members: [member('0x1')] }),
})
await teamSync.reconcileNow('setup')
await teamSync.publish({ kind: 'team.leader.added', externalId: 'g1', memberKey: '0x1' })
assert.equal(membersOf(1).get('0x1').is_leader, 1)
assert.equal(activeMembers(1).length, 1, 'promotion is not a join')
await teamSync.publish({ kind: 'team.leader.removed', externalId: 'g1', memberKey: '0x1' })
assert.equal(membersOf(1).get('0x1').is_leader, 0)
assert.equal(activeMembers(1).length, 1, 'demotion is not a departure')
})
test('a leadership delta for an unknown member creates nobody', async () => {
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) })
await teamSync.reconcileNow('setup')
await teamSync.publish({ kind: 'team.leader.added', externalId: 'g1', memberKey: '0xdead' })
assert.equal(activeMembers(1).length, 0, 'a promotion is not evidence of membership')
})
test('an event for an unknown team asks for a reconciliation instead of guessing', async () => {
provide()
await teamSync.publish({ kind: 'team.member.added', externalId: 'nope', memberKey: '0x1' })
assert.equal(store.teams.length, 0)
})
test('publish is a no-op when no provider is registered', async () => {
await teamSync.publish({ kind: 'team.member.added', externalId: 'g1', memberKey: '0x1' })
assert.equal(store.teams.length, 0)
})
// ── Scheduling ─────────────────────────────────────────────────────────────
test('a run already in flight is joined rather than run twice', async () => {
let calls = 0
let release
const gate = new Promise((resolve) => { release = resolve })
provide({
getTeams: async () => {
calls += 1
await gate
return { ok: true, teams: [] }
},
})
const first = teamSync.reconcileNow('first')
const second = await teamSync.reconcileNow('second')
assert.equal(second.joined, true)
release()
await first
assert.equal(calls, 1, 'the lock is what stops two runs writing the same rows')
})
test('the poll interval falls back and is floored against a bad setting', async () => {
patch(settings, 'get', async () => 'not a number')
assert.equal(await teamSync.intervalSeconds(), teamSync.DEFAULT_INTERVAL_S)
patch(settings, 'get', async () => '5')
assert.equal(await teamSync.intervalSeconds(), teamSync.DEFAULT_INTERVAL_S, 'a hot loop is not a valid interval')
patch(settings, 'get', async () => '120')
assert.equal(await teamSync.intervalSeconds(), 120)
patch(settings, 'get', async () => { throw new Error('db down') })
assert.equal(await teamSync.intervalSeconds(), teamSync.DEFAULT_INTERVAL_S)
})
test('backoff grows with failures and is capped at the poll interval', async () => {
assert.equal(teamSync.backoffSeconds(0, 900), 900, 'no failures means the ordinary poll')
assert.equal(teamSync.backoffSeconds(1, 900), 30)
assert.equal(teamSync.backoffSeconds(2, 900), 60)
assert.ok(teamSync.backoffSeconds(4, 900) < 900)
assert.equal(teamSync.backoffSeconds(50, 900), 900, 'a module down for a day must recover promptly, not in weeks')
})
test('start() is inert with no provider registered', async () => {
await teamSync.start()
assert.equal(store.teams.length, 0)
})
// ── Core's own activity items (§4.2) ───────────────────────────────────────
//
// The reconciler's half of the feed: which items it DECIDES to emit. The feed's
// own rules — visibility, dedupe, retention — live in teamActivity.test.js.
const kinds = () => store.activity.map((a) => a.kind)
const summaries = () => store.activity.map((a) => a.summary)
const withMembers = (members, leaders = []) => ({
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
getTeamMembers: async () => ({ ok: true, members }),
getTeamLeaders: async () => ({ ok: true, leaders }),
})
// Re-provide between runs: the tests above establish that a provider registers
// once, so a second answer means a fresh registration.
async function resync(overrides, reason) {
registries._reset()
provide(overrides)
return teamSync.reconcileNow(reason)
}
test('the FIRST roster emits nothing — an import is not 155 people joining', async () => {
provide(withMembers([member('0x1'), member('0x2')]))
await teamSync.reconcileNow('setup')
assert.equal(activeMembers(1).length, 2, 'the members did land')
assert.deepEqual(store.activity, [], 'and none of them was announced')
})
test('a member arriving after the first roster is announced', async () => {
provide(withMembers([member('0x1')]))
await teamSync.reconcileNow('setup')
await resync(withMembers([member('0x1'), member('0x2', { displayName: 'Brenna' })]), 'test')
assert.deepEqual(kinds(), ['core.member.joined'])
assert.deepEqual(summaries(), ['Brenna joined'])
assert.equal(store.activity[0].actorMemberKey, '0x2')
})
test('a member who leaves is announced by the name core last knew them by', async () => {
provide(withMembers([member('0x1'), member('0x2', { displayName: 'Brenna' })]))
await teamSync.reconcileNow('setup')
await resync(withMembers([member('0x1')]), 'test')
// The module no longer mentions them at all, so the display name can only come
// from the row core is about to depart — which is why the sync reads the ROWS
// before the upsert rather than just the keys.
assert.deepEqual(kinds(), ['core.member.left'])
assert.deepEqual(summaries(), ['Brenna left'])
})
test('an INCOMPLETE answer announces no departures, because it removed none', async () => {
provide(withMembers([member('0x1'), member('0x2')]))
await teamSync.reconcileNow('setup')
await resync({
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
getTeamMembers: async () => ({ ok: true, complete: false, members: [member('0x1')] }),
getTeamLeaders: async () => ({ ok: true, leaders: [] }),
}, 'partial')
assert.equal(activeMembers(1).length, 2, 'nobody was removed')
assert.deepEqual(store.activity, [], 'so nobody is announced as leaving')
})
test('promotion and demotion are announced; unchanged leadership is not', async () => {
provide(withMembers([member('0x1'), member('0x2')], ['0x1']))
await teamSync.reconcileNow('setup')
await resync(withMembers([member('0x1'), member('0x2')], ['0x2']), 'test')
assert.deepEqual(kinds(), ['core.leader.changed', 'core.leader.changed'])
assert.deepEqual(summaries().sort(), ['0x1 stepped down as a leader', '0x2 became a leader'])
})
test('a member who joins already a leader is announced once, as joining', async () => {
provide(withMembers([member('0x1')], ['0x1']))
await teamSync.reconcileNow('setup')
await resync(withMembers([member('0x1'), member('0x2')], ['0x1', '0x2']), 'test')
assert.deepEqual(kinds(), ['core.member.joined'], 'never a non-leader here to be promoted from')
})
test('a departing leader is announced as leaving, not as stepping down', async () => {
provide(withMembers([member('0x1'), member('0x2')], ['0x1', '0x2']))
await teamSync.reconcileNow('setup')
await resync(withMembers([member('0x1')], ['0x1']), 'test')
assert.deepEqual(kinds(), ['core.member.left'], 'one event, not two')
})
test('a refused leadership answer announces nothing — it demoted nobody', async () => {
provide(withMembers([member('0x1')], ['0x1']))
await teamSync.reconcileNow('setup')
await resync({
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
getTeamMembers: async () => ({ ok: true, members: [member('0x1')] }),
getTeamLeaders: async () => ({ ok: false, reason: 'unavailable' }),
}, 'test')
assert.deepEqual(store.activity, [])
assert.equal(activeMembers(1)[0].is_leader, 1, 'and left the stored value alone')
})
test('a rename is announced on the successor, naming the old name', async () => {
provide({
getTeams: async () => ({ ok: true, teams: [team('g1', 'The Silver Hand')] }),
getTeamMembers: async () => ({ ok: true, members: [] }),
getTeamLeaders: async () => ({ ok: true, leaders: [] }),
})
await teamSync.reconcileNow('setup')
await resync({
getTeams: async () => ({ ok: true, teams: [team('g1', 'The Golden Hand')] }),
getTeamMembers: async () => ({ ok: true, members: [] }),
getTeamLeaders: async () => ({ ok: true, leaders: [] }),
}, 'rename')
const renames = store.activity.filter((a) => a.kind === 'core.team.renamed')
assert.equal(renames.length, 1)
assert.equal(renames[0].summary, 'Renamed from The Silver Hand')
// The SUCCESSOR row, not the archived one: the archived row is a read-only
// record of what came before, and the reader is looking at the live page.
const successor = store.teams.find((t) => t.status === 'active')
assert.equal(renames[0].teamId, successor.id)
})
test('a member with no display name is announced without leaking the member key', async () => {
provide(withMembers([member('0x1')]))
await teamSync.reconcileNow('setup')
await resync(withMembers([member('0x1'), member('0x2', { displayName: null })]), 'test')
assert.deepEqual(summaries(), ['A member joined'])
})
test('a feed write that fails never fails the sync', async () => {
patch(activity, 'logCore', async () => { throw new Error('table gone') })
provide(withMembers([member('0x1')]))
await teamSync.reconcileNow('setup')
const result = await resync(withMembers([member('0x1'), member('0x2')]), 'test')
assert.equal(result.ok, true)
assert.equal(activeMembers(1).length, 2, 'the roster is the source of truth and it applied')
})