From 225663d62e65183fac43e5f314e32b3eb30b4a77 Mon Sep 17 00:00:00 2001
From: wtclaude
Date: Mon, 17 Aug 2026 14:40:26 -0500
Subject: [PATCH 01/35] feat(teams): core schema for Teams, membership, sync
state and moderation
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The six core tables Team core is built on (docs/website/TEAMS.md §2.1, §2.5,
§2.5.1, §2.9), plus the §2.10 account-deletion decisions expressed as foreign
keys rather than left to whatever the defaults happened to be.
Every table is core-internal (§10.3): a module populates them through the team
provider and must never read or write one directly. They carry no _
prefix, correctly -- MODULE_API.md §2.6's prefix rule binds modules, and these
are core's.
team_forum_grants lands in this phase rather than in phase 4, so the four-path
resolver is written once and its non-contamination tests are real. Nothing
writes it yet; the grant/revoke flow, the per-Team cap and the leader UI are
phase 4's.
Two departures from the SQL as TEAMS.md sketched it, both recorded in the file:
- team_forum_grants.user_id is nullable with ON DELETE SET NULL, following
§2.10 (the audit trail of who granted whom must survive the account) rather
than §2.5's CASCADE.
- its uniqueness marker is derived from revoked_at alone, with user_id moved
into the unique KEY. §2.5's `active_user AS (IF(revoked_at IS NULL, user_id,
NULL))` cannot coexist with the line above: MariaDB refuses ON DELETE SET
NULL on a foreign key whose column is a base column of a STORED generated
column (error 1901). The semantics are identical -- at most one active grant
per (team, user), unlimited revoked rows.
Verified by running ensureSchema() against MariaDB 11: all six tables create,
both generated columns materialise, and every foreign key's delete rule matches
§2.10's table. The uniqueness encoding was checked directly -- a second active
grant for the same (team, user) is rejected 1062 while revoked rows accumulate
freely.
Refs docs/website/TEAMS.md Part 12 phase 2
Co-Authored-By: Claude
---
server/db/schema.sql | 196 +++++++++++++++++++++++++++++++++++++++++++
1 file changed, 196 insertions(+)
diff --git a/server/db/schema.sql b/server/db/schema.sql
index 3a6c6e5..f12fc68 100644
--- a/server/db/schema.sql
+++ b/server/db/schema.sql
@@ -845,6 +845,202 @@ 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 `_` 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,
+ -- Staff may change what is DISPLAYED without touching identity (§2.8.3).
+ display_name_override VARCHAR(160) NULL,
+ -- The successor row written at archive time when this Team was renamed, so the
+ -- old slug can still resolve and explain itself rather than 404 (§2.2).
+ succeeded_by INT NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ archived_at DATETIME NULL,
+ archived_reason VARCHAR(64) NULL, -- 'disbanded' | 'renamed' | 'staff'
+ -- A generated column is how "unique among ACTIVE rows only" is expressed without
+ -- a partial index (MariaDB has none): NULL never collides in a UNIQUE key, so
+ -- any number of archived rows may share an external_id.
+ active_key VARCHAR(191) AS (IF(status='active', external_id, NULL)) STORED,
+ active_slug VARCHAR(191) AS (IF(status='active', slug, NULL)) STORED,
+ UNIQUE KEY uq_teams_active (module_id, active_key),
+ UNIQUE KEY uq_teams_active_slug (active_slug),
+ INDEX idx_teams_status (status),
+ INDEX idx_teams_slug (slug),
+ INDEX idx_teams_review (hidden, hidden_reason),
+ -- Self-referential and deliberately SET NULL: a successor may itself be archived
+ -- and eventually pruned, and losing the pointer must not take the old row with it.
+ CONSTRAINT fk_teams_succeeded_by FOREIGN KEY (succeeded_by) REFERENCES teams(id) ON DELETE SET NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- The membership PROJECTION. Module-authoritative; core only mirrors it, and the
+-- sync is the ONLY writer (§2.5 path 1). Rows are soft-departed rather than
+-- deleted so history and rejoin detection survive, and so the activity feed can
+-- still name a departed member.
+--
+-- user_id is resolved BY THE MODULE (it owns the game↔site link table); core never
+-- resolves it, because doing so would be core reading a module's table by name.
+CREATE TABLE IF NOT EXISTS team_members (
+ team_id INT NOT NULL,
+ member_key VARCHAR(191) NOT NULL, -- module's stable member id (UO: character serial)
+ display_name VARCHAR(160) NULL, -- in-game name
+ user_id INT NULL, -- resolved by the MODULE; NULL = unlinked
+ is_leader TINYINT(1) NOT NULL DEFAULT 0,
+ rank_label VARCHAR(48) NULL, -- module vocabulary, opaque to core
+ online TINYINT(1) NOT NULL DEFAULT 0,
+ status ENUM('active','departed') NOT NULL DEFAULT 'active',
+ first_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ departed_at DATETIME NULL,
+ PRIMARY KEY (team_id, member_key),
+ -- SET NULL, not CASCADE (§2.10): deleting a site account does not remove the
+ -- character from the guild — only the link to the site goes.
+ CONSTRAINT fk_team_members_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
+ CONSTRAINT fk_team_members_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
+ INDEX idx_team_members_user (user_id),
+ INDEX idx_team_members_status (team_id, status)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- Freshness of the module's answer. One row per module. THE table invariant 1
+-- ("module unavailability is staleness, never emptiness") is enforced against.
+CREATE TABLE IF NOT EXISTS team_sync_state (
+ module_id VARCHAR(32) NOT NULL PRIMARY KEY,
+ last_attempt_at DATETIME NULL,
+ last_success_at DATETIME NULL,
+ consecutive_failures INT NOT NULL DEFAULT 0,
+ last_error VARCHAR(500) NULL,
+ -- The quarantine for §2.4's mass-deletion guard: an authoritative-but-empty
+ -- answer is remembered here and applied only if the NEXT one agrees.
+ pending_empty_since DATETIME NULL,
+ INDEX idx_team_sync_success (last_success_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- Staff leadership overrides (§2.5.1), applied ON TOP of the synced value at read
+-- time. The projection is never mutated: the sync keeps writing what the game
+-- says and this keeps saying what staff decided, which is the whole point — an
+-- override the sync clobbered every 15 minutes would be useless.
+CREATE TABLE IF NOT EXISTS team_leader_overrides (
+ team_id INT NOT NULL,
+ member_key VARCHAR(191) NOT NULL,
+ effect ENUM('grant','deny') NOT NULL,
+ actor_user_id INT NULL,
+ actor_username VARCHAR(32) NULL, -- snapshot, so the record survives the account
+ reason VARCHAR(255) NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (team_id, member_key),
+ CONSTRAINT fk_tlo_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
+ CONSTRAINT fk_tlo_actor FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- Forum access grants (§2.5 path 3) — an append-only grant/revoke ledger that is
+-- ALSO the current state. An active grant is one with revoked_at IS NULL, and a
+-- generated column is how "one active grant per (team,user)" is expressed without
+-- a partial index (MariaDB has none): NULL never collides in a UNIQUE key.
+--
+-- The table lands here, in the phase that builds the resolver, so forumAccess() is
+-- written once and its non-contamination tests are real. The grant/revoke FLOW,
+-- the per-Team cap and the leader UI are phase 4's; nothing writes this table yet.
+--
+-- user_id is NULLABLE and SET NULL, which contradicts the sketch in TEAMS.md §2.5
+-- and follows §2.10, which settled it deliberately: CASCADE would delete the audit
+-- trail of who granted whom, which is exactly what an audit exists to survive. The
+-- username snapshots keep the record readable after the account is gone.
+--
+-- THE TWO CANNOT BOTH BE HAD AS §2.5 WROTE THEM, and this is why the marker below
+-- is a bare flag rather than §2.5's `active_user AS (IF(revoked_at IS NULL,
+-- user_id, NULL))`. MariaDB refuses `ON DELETE SET NULL` on a foreign key whose
+-- column is a base column of a STORED generated column (ER_GENERATED_COLUMN_
+-- FUNCTION_IS_NOT_ALLOWED, 1901) — so §2.5's generated column forces §2.10's
+-- CASCADE, and the audit trail with it. Deriving the marker from `revoked_at`
+-- ALONE and putting user_id in the KEY instead gives identical semantics: at most
+-- one active row per (team_id, user_id), unlimited revoked rows, and user_id free
+-- to be a SET NULL foreign key. Verified against MariaDB 11 both ways.
+CREATE TABLE IF NOT EXISTS team_forum_grants (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ team_id INT NOT NULL,
+ user_id INT NULL,
+ username VARCHAR(32) NULL, -- snapshot of the grantee at grant time
+ granted_by INT NULL, -- NULL for a system grant, or a deleted actor
+ granted_username VARCHAR(32) NULL, -- snapshot of the actor
+ granted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ reason VARCHAR(255) NULL,
+ revoked_by INT NULL,
+ revoked_username VARCHAR(32) NULL,
+ revoked_at DATETIME NULL,
+ revoke_reason VARCHAR(255) NULL,
+ active_marker TINYINT(1) AS (IF(revoked_at IS NULL, 1, NULL)) STORED,
+ UNIQUE KEY uq_team_forum_grant_active (team_id, user_id, active_marker),
+ CONSTRAINT fk_tfg_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
+ CONSTRAINT fk_tfg_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
+ CONSTRAINT fk_tfg_granted_by FOREIGN KEY (granted_by) REFERENCES users(id) ON DELETE SET NULL,
+ CONSTRAINT fk_tfg_revoked_by FOREIGN KEY (revoked_by) REFERENCES users(id) ON DELETE SET NULL,
+ INDEX idx_tfg_user (user_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- The §2.9 approval queue. A MODERATOR performing one of the three actions that
+-- publish untrusted game-sourced strings creates a pending row here; an ADMIN
+-- performing one applies it immediately. Rows are kept after a decision — "a
+-- moderator asked to publish this name and an admin refused" is the record worth
+-- having.
+--
+-- `action` + `payload` means a fourth gated action is an enum value rather than a
+-- schema change. That is room to extend, not an invitation: nothing else is gated
+-- today, and nothing should be without asking §2.9's question first.
+CREATE TABLE IF NOT EXISTS team_moderation_requests (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ team_id INT NOT NULL,
+ action ENUM('unhide','display_name_override','clear_display_name_override') NOT NULL,
+ payload JSON NULL, -- e.g. { "displayName": "…" }
+ reason VARCHAR(255) NULL,
+ requested_by INT NULL,
+ requested_username VARCHAR(32) NULL, -- snapshot (§2.10)
+ requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ status ENUM('pending','approved','rejected','withdrawn') NOT NULL DEFAULT 'pending',
+ decided_by INT NULL,
+ decided_username VARCHAR(32) NULL,
+ decided_at DATETIME NULL,
+ decision_note VARCHAR(255) NULL,
+ CONSTRAINT fk_tmr_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
+ CONSTRAINT fk_tmr_requested_by FOREIGN KEY (requested_by) REFERENCES users(id) ON DELETE SET NULL,
+ CONSTRAINT fk_tmr_decided_by FOREIGN KEY (decided_by) REFERENCES users(id) ON DELETE SET NULL,
+ INDEX idx_tmr_queue (status, requested_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
-- Migrations for databases created before the wiki upgrade. Each statement uses
-- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get
-- these columns from the CREATE TABLE above; existing installs get them here.
--
2.49.1
From 8b63ffc725405c8e6525894bfb2a27af40a5c6f5 Mon Sep 17 00:00:00 2001
From: wtclaude
Date: Mon, 17 Aug 2026 14:44:45 -0500
Subject: [PATCH 02/35] feat(modules): registerTeamProvider, and a call path
that cannot answer "empty"
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The registration a module uses to become the authoritative source of Teams
(docs/website/TEAMS.md §2.3), plus the wrapper core calls it through.
registerTeamProvider is the first registration where core CALLS THE MODULE and
waits for an answer. Every existing one is either the module claiming a mount or
core notifying it; the closest precedent is registerAnnounceLeg's dispatch, and
this is modelled on it rather than invented. It also holds a single value rather
than a map, unlike every other registry: Teams have one authoritative source by
construction, and two modules answering "what teams exist" would produce two
disjoint sets under one `teams` table with no rule for merging them. A second
registration is therefore a collision, named against the module that holds it.
teamProvider.js is where invariant 1 -- module unavailability is staleness,
never emptiness -- is actually enforced. It is deliberately generous about what
counts as a failure: a rejected promise, a synchronous throw, a timeout, a
non-object, a bare array, a missing `ok`, or a structurally malformed row all
leave as the same `{ ok: false }` a module would have sent on purpose. There is
no shape a broken provider can produce that arrives at the reconciler looking
like an authoritative empty list -- which is the entire argument for the
envelope, since a bare array has exactly one such shape and it is the one a
module returns while its sidecar is still connecting.
A malformed row fails the whole call rather than being dropped. Salvaging is the
dangerous option: one unreadable member quietly omitted from a roster is
indistinguishable, downstream, from that member having left, and the sync would
mark them departed on the strength of a broken payload. Refusing costs one stale
interval.
The deadline timer is unreffed as well as cleared. Clearing covers the case
where the race settles; it cannot cover a module promise that never settles at
all, where nothing exists to clear until the deadline fires. Caught by the test
file taking 10.2s to run 265ms of assertions -- the same class of bug as the
mariadb pool that used to hold the suite open (test/_setup.js). 292ms now.
28 tests. Full suite 770 passed, 0 failed.
Refs docs/website/TEAMS.md §2.3, Part 12 phase 2
Co-Authored-By: Claude
---
server/src/model/teams/teamProvider.js | 182 ++++++++++++++++
server/src/modules/loader.js | 9 +
server/src/modules/registries.js | 58 ++++-
server/test/teamProvider.test.js | 289 +++++++++++++++++++++++++
4 files changed, 536 insertions(+), 2 deletions(-)
create mode 100644 server/src/model/teams/teamProvider.js
create mode 100644 server/test/teamProvider.test.js
diff --git a/server/src/model/teams/teamProvider.js b/server/src/model/teams/teamProvider.js
new file mode 100644
index 0000000..945f983
--- /dev/null
+++ b/server/src/model/teams/teamProvider.js
@@ -0,0 +1,182 @@
+// ── Calling the Team provider ──────────────────────────────────────────────
+//
+// The one place core asks a module a question and waits for the answer
+// (docs/website/TEAMS.md §2.3). Everything here exists to serve invariant 1:
+//
+// **Module unavailability is staleness, never emptiness.**
+//
+// No Team subsystem may apply a destructive result derived from a failed,
+// timed-out or unanswered module call. This file is where "failed" is defined, and
+// it is deliberately generous about what counts: a rejected promise, a timeout, a
+// non-object, a missing `ok`, or a structurally malformed row all leave with the
+// same `{ ok: false }` the module would have sent deliberately.
+//
+// **There is no shape a failure can take that core reads as "zero teams".** That
+// is the whole argument for the envelope, and the reason the provider signature is
+// not the obvious `getTeams(): Team[]` — a bare array has exactly one such shape,
+// `[]`, and it is the one a module returns while its sidecar is still connecting.
+//
+// Nothing here touches the database. It calls the module and hands back a value
+// the reconciler can trust the SHAPE of; whether to ACT on it is §2.4's question.
+
+const registries = require('../../modules/registries')
+const log = require('../../utils/logger')('teams')
+
+// The budget from §2.3. A provider is answering from its own cache or its own
+// sidecar client, both of which have their own timeouts well inside this; a call
+// that reaches ten seconds is wedged, not slow.
+const CALL_TIMEOUT_MS = 10_000
+
+/** A uniform refusal. `reason` is for the operator, via team_sync_state. */
+const fail = (reason) => ({ ok: false, reason })
+
+/**
+ * Await `promise` with a timeout that cannot outlive the call.
+ *
+ * The timer is always cleared — including on the winning path — because an
+ * uncleared 10s timer holds the event loop open, which in a test run means the
+ * process hangs long after the assertions passed. The suite already learned this
+ * one from a mariadb pool (test/_setup.js).
+ *
+ * It is also `unref`ed, which covers the case clearing cannot: when the module's
+ * promise NEVER settles, the race stays pending and there is nothing to clear
+ * until the deadline fires. An unreffed timer still fires normally while the
+ * process is alive — the server's own listener is what keeps it alive — but it no
+ * longer holds a shutdown open for ten seconds waiting on a module that is not
+ * going to answer.
+ */
+function withTimeout(promise, ms) {
+ let timer
+ const timeout = new Promise((resolve) => {
+ timer = setTimeout(() => resolve(fail(`provider did not answer within ${ms}ms`)), ms)
+ if (typeof timer.unref === 'function') timer.unref()
+ })
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer))
+}
+
+/**
+ * Call one provider method and normalise whatever comes back into an envelope.
+ *
+ * `normalise` is only ever run on an `ok` answer, and may itself return a refusal
+ * — a structurally malformed row is treated as a failed call rather than as data
+ * to salvage. Salvaging is the dangerous option: dropping one unreadable member
+ * from a roster is indistinguishable, downstream, from that member having left,
+ * and the sync would mark them departed. Refusing costs one stale interval.
+ */
+async function call(method, normalise, ...args) {
+ const provider = registries.registeredTeamProvider()
+ if (!provider) return fail('no team provider is registered')
+
+ let answer
+ try {
+ answer = await withTimeout(Promise.resolve().then(() => provider[method](...args)), CALL_TIMEOUT_MS)
+ } catch (err) {
+ // A rejected promise is a module that threw, which is exactly as
+ // unauthoritative as one that answered `{ ok: false }`.
+ return fail(`${method}() threw: ${err.message}`)
+ }
+
+ if (!answer || typeof answer !== 'object' || Array.isArray(answer)) {
+ return fail(`${method}() returned ${Array.isArray(answer) ? 'an array' : typeof answer}, not an envelope`)
+ }
+ // `ok` must be present and true. A module that forgot the field is not one
+ // asserting authority, and reading a missing field as truthy would put the
+ // single most consequential decision in this file on a typo.
+ if (answer.ok !== true) return fail(answer.reason || `${method}() answered not-ok`)
+
+ const normalised = normalise(answer)
+ if (normalised.ok === false) {
+ log.warn('team provider answered with a malformed payload', {
+ owner: provider.owner, method, reason: normalised.reason,
+ })
+ }
+ return normalised
+}
+
+// `complete` defaults to TRUE when the module omits it, matching §2.3: the
+// envelope's optional field marks a partial answer, so its absence is the
+// ordinary authoritative case. A module that cannot enumerate exhaustively says
+// so explicitly.
+const isComplete = (answer) => answer.complete !== false
+
+const str = (v) => (typeof v === 'string' ? v.trim() : '')
+
+/** `{ ok, complete, teams: [{ externalId, name, abbr, meta }] }` */
+function normaliseTeams(answer) {
+ if (!Array.isArray(answer.teams)) return fail('getTeams() answered ok with no teams array')
+ const teams = []
+ for (const raw of answer.teams) {
+ const externalId = str(raw && raw.externalId)
+ const name = str(raw && raw.name)
+ // Both are load-bearing and neither has a safe default: externalId is the
+ // identity the whole rename rule (§2.2) turns on, and a Team with no name has
+ // no slug and no page.
+ if (!externalId) return fail('a team in getTeams() has no externalId')
+ if (!name) return fail(`team "${externalId}" has no name`)
+ teams.push({
+ externalId,
+ name,
+ abbr: str(raw.abbr) || null,
+ // Opaque by contract (§10.5) — stored and handed back, never branched on.
+ meta: raw.meta && typeof raw.meta === 'object' ? raw.meta : null,
+ })
+ }
+ return { ok: true, complete: isComplete(answer), teams }
+}
+
+/** `{ ok, complete, members: [{ memberKey, displayName, rankLabel, leader, online, userId }] }` */
+function normaliseMembers(answer) {
+ if (!Array.isArray(answer.members)) return fail('getTeamMembers() answered ok with no members array')
+ const members = []
+ const seen = new Set()
+ for (const raw of answer.members) {
+ const memberKey = str(raw && raw.memberKey)
+ if (!memberKey) return fail('a member has no memberKey')
+ // A duplicate key would upsert twice and inflate no count but confuse every
+ // reader; it also means the module's own identity rule is broken, which is
+ // worth surfacing rather than quietly collapsing.
+ if (seen.has(memberKey)) return fail(`member "${memberKey}" appears twice`)
+ seen.add(memberKey)
+ members.push({
+ memberKey,
+ displayName: str(raw.displayName) || null,
+ rankLabel: str(raw.rankLabel) || null,
+ leader: Boolean(raw.leader),
+ online: Boolean(raw.online),
+ // Resolved BY THE MODULE — it owns the game↔site link table (§2.3). Core
+ // takes the number and never looks it up.
+ userId: Number.isInteger(raw.userId) && raw.userId > 0 ? raw.userId : null,
+ })
+ }
+ return { ok: true, complete: isComplete(answer), members }
+}
+
+/** `{ ok, leaders: [memberKey] }` */
+function normaliseLeaders(answer) {
+ if (!Array.isArray(answer.leaders)) return fail('getTeamLeaders() answered ok with no leaders array')
+ const leaders = []
+ for (const raw of answer.leaders) {
+ const key = str(raw)
+ if (!key) return fail('a leader entry is not a member key')
+ if (!leaders.includes(key)) leaders.push(key)
+ }
+ return { ok: true, leaders }
+}
+
+const getTeams = () => call('getTeams', normaliseTeams)
+const getTeamMembers = (externalId) => call('getTeamMembers', normaliseMembers, externalId)
+const getTeamLeaders = (externalId) => call('getTeamLeaders', normaliseLeaders, externalId)
+
+/** Which module is authoritative, or null. The reconciler keys sync state on it. */
+const providerModuleId = () => {
+ const provider = registries.registeredTeamProvider()
+ return provider ? provider.owner : null
+}
+
+module.exports = {
+ getTeams,
+ getTeamMembers,
+ getTeamLeaders,
+ providerModuleId,
+ CALL_TIMEOUT_MS,
+}
diff --git a/server/src/modules/loader.js b/server/src/modules/loader.js
index 53c7aad..51faad2 100644
--- a/server/src/modules/loader.js
+++ b/server/src/modules/loader.js
@@ -240,6 +240,15 @@ 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)
+ },
// 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
diff --git a/server/src/modules/registries.js b/server/src/modules/registries.js
index 938765f..7165f8e 100644
--- a/server/src/modules/registries.js
+++ b/server/src/modules/registries.js
@@ -58,6 +58,16 @@ const legs = new Map()
// a collision with a name attached rather than a silently doubled side effect.
const postHooks = new Map()
+// { owner, getTeams, getTeamMembers, getTeamLeaders } or null — the Team provider
+// (API 1.6.0, TEAMS.md §2.3).
+//
+// A SINGLE value rather than a Map, unlike every registry above it, and that is
+// the contract: one provider per deployment. Teams have one authoritative source
+// by construction — two modules answering "what teams exist" would produce two
+// disjoint sets under one `teams` table with no rule for merging them, so a
+// second registration is a collision rather than an addition.
+let teamProvider = null
+
let coreRegistered = false
// Stream ids that predate the module system and may not carry their owner's
@@ -196,6 +206,14 @@ const announceLegIds = () => [...legs.keys()]
/** One leg, or null. */
const announceLeg = (leg) => legs.get(leg) || null
+// ── Team provider (TEAMS.md §2.3) ──────────────────────────────────────────
+
+/** The registered provider, or null when no module supplies one. */
+const registeredTeamProvider = () => teamProvider
+
+/** Is there a Team provider at all? Read by the reconciler and the read API. */
+const hasTeamProvider = () => teamProvider !== null
+
// ── Shape checks, run the moment a registrant calls ────────────────────────
//
// Split from the collision checks below on the same line PR 3 drew through
@@ -225,6 +243,23 @@ function checkLegShape(entry) {
return { leg, label: label || leg, dispatch, classify }
}
+// All three methods are REQUIRED, with no optional half. A provider that could
+// list Teams but not their members would leave core holding Teams it can never
+// populate, and the reconciler has no sensible behaviour for that — it is not the
+// same as a call that fails, which is staleness and already handled (§2.4). A
+// module unable to answer one of the three answers `{ ok: false }` at call time.
+function checkTeamProviderShape(entry) {
+ const provider = entry || {}
+ const out = {}
+ for (const name of ['getTeams', 'getTeamMembers', 'getTeamLeaders']) {
+ if (typeof provider[name] !== 'function') {
+ throw new Error(`registerTeamProvider: ${name}() is missing or not a function`)
+ }
+ out[name] = provider[name]
+ }
+ return out
+}
+
/**
* `registerPostHook({ onSaved, onDeleted })` — both optional, at least one
* required. A registration with neither is a subscription that can never fire,
@@ -265,7 +300,7 @@ function checkExtensionShape(slot, router, specFile) {
* `allStreams()` / `announceLeg()` / the slot routers until `apply()`.
*/
function stage(owner) {
- const staged = { owner, streams: [], legs: [], extensions: [], postHooks: [] }
+ const staged = { owner, streams: [], legs: [], extensions: [], postHooks: [], teamProviders: [] }
return {
staged,
registerNotificationStreams(entries) {
@@ -281,6 +316,9 @@ function stage(owner) {
registerPostHook(entry) {
staged.postHooks.push(checkPostHookShape(entry))
},
+ registerTeamProvider(entry) {
+ staged.teamProviders.push(checkTeamProviderShape(entry))
+ },
}
}
@@ -293,7 +331,14 @@ function stage(owner) {
* PR 2 learned to protect (mounting inside the scan loop made every collision
* look like it was with core).
*/
-function apply({ owner, streams: newStreams, legs: newLegs, extensions: newExtensions, postHooks: newPostHooks = [] }) {
+function apply({
+ owner,
+ streams: newStreams,
+ legs: newLegs,
+ extensions: newExtensions,
+ postHooks: newPostHooks = [],
+ teamProviders: newTeamProviders = [],
+}) {
// ── validate ──
const seenStreams = new Set()
for (const s of newStreams) {
@@ -332,6 +377,11 @@ function apply({ owner, streams: newStreams, legs: newLegs, extensions: newExten
throw new Error(`"${owner}" already registered a post hook`)
}
+ if (newTeamProviders.length > 1) throw new Error(`"${owner}" registered more than one team provider`)
+ if (newTeamProviders.length && teamProvider) {
+ throw new Error(`a team provider is already registered by "${teamProvider.owner}"`)
+ }
+
// ── commit — nothing below can fail ──
for (const s of newStreams) {
streamOwners.set(s.id, owner)
@@ -345,6 +395,7 @@ function apply({ owner, streams: newStreams, legs: newLegs, extensions: newExten
entry.router.use(x.router)
}
for (const h of newPostHooks) postHooks.set(owner, h)
+ for (const p of newTeamProviders) teamProvider = { owner, ...p }
}
// ── Core's own registrations ───────────────────────────────────────────────
@@ -410,6 +461,7 @@ function _reset() {
streamOwners.clear()
legs.clear()
postHooks.clear()
+ teamProvider = null
coreRegistered = false
}
@@ -427,6 +479,8 @@ module.exports = {
announceLeg,
postHookEntries,
dispatchPostHook,
+ registeredTeamProvider,
+ hasTeamProvider,
stage,
apply,
registerCore,
diff --git a/server/test/teamProvider.test.js b/server/test/teamProvider.test.js
new file mode 100644
index 0000000..75bdd5b
--- /dev/null
+++ b/server/test/teamProvider.test.js
@@ -0,0 +1,289 @@
+// The Team provider registration and the guarded call path
+// (docs/website/TEAMS.md §2.3).
+//
+// Almost every test here is invariant 1 asked a different way: **module
+// unavailability is staleness, never emptiness.** The value of this file is that
+// it enumerates the shapes a broken provider can produce and asserts that none of
+// them arrives at the reconciler looking like authoritative data.
+const { test, beforeEach, afterEach } = require('node:test')
+const assert = require('node:assert/strict')
+
+const registries = require('../src/modules/registries')
+const teamProvider = require('../src/model/teams/teamProvider')
+
+// Register a provider the way a module does: stage, then commit.
+function register(owner, provider) {
+ const api = registries.stage(owner)
+ api.registerTeamProvider(provider)
+ registries.apply(api.staged)
+}
+
+const ok = () => ({
+ getTeams: async () => ({ ok: true, teams: [{ externalId: 'g1', name: 'The Silver Hand' }] }),
+ getTeamMembers: async () => ({ ok: true, members: [{ memberKey: '0x1', displayName: 'Aldric' }] }),
+ getTeamLeaders: async () => ({ ok: true, leaders: ['0x1'] }),
+})
+
+beforeEach(() => registries._reset())
+afterEach(() => registries._reset())
+
+// ── Registration ───────────────────────────────────────────────────────────
+
+test('a provider is readable only after apply(), not at stage time', () => {
+ const api = registries.stage('uo')
+ api.registerTeamProvider(ok())
+ assert.equal(registries.hasTeamProvider(), false, 'staging must not publish')
+
+ registries.apply(api.staged)
+ assert.equal(registries.hasTeamProvider(), true)
+ assert.equal(registries.registeredTeamProvider().owner, 'uo')
+})
+
+test('all three methods are required', () => {
+ const api = registries.stage('uo')
+ for (const missing of ['getTeams', 'getTeamMembers', 'getTeamLeaders']) {
+ const provider = ok()
+ delete provider[missing]
+ assert.throws(() => api.registerTeamProvider(provider), new RegExp(`${missing}\\(\\) is missing`))
+ }
+ // A non-function is the same failure, and is the likelier typo.
+ assert.throws(() => api.registerTeamProvider({ ...ok(), getTeams: 'yes' }), /getTeams\(\) is missing or not a function/)
+})
+
+test('a second provider is a collision naming the module that holds it', () => {
+ register('uo', ok())
+ const second = registries.stage('other')
+ second.registerTeamProvider(ok())
+ assert.throws(() => registries.apply(second.staged), /already registered by "uo"/)
+ // The first registration is untouched by the rejected second.
+ assert.equal(registries.registeredTeamProvider().owner, 'uo')
+})
+
+test('one module registering twice in one batch is rejected', () => {
+ const api = registries.stage('uo')
+ api.registerTeamProvider(ok())
+ api.registerTeamProvider(ok())
+ assert.throws(() => registries.apply(api.staged), /more than one team provider/)
+ assert.equal(registries.hasTeamProvider(), false, 'the whole batch is refused')
+})
+
+test('a rejected batch leaves no provider behind, even when its other claims are fine', () => {
+ const api = registries.stage('uo')
+ api.registerTeamProvider(ok())
+ api.registerNotificationStreams([{ id: 'uo.thing', label: 'Thing' }])
+ api.registerNotificationStreams([{ id: 'uo.thing', label: 'Thing' }]) // duplicate
+ assert.throws(() => registries.apply(api.staged))
+ assert.equal(registries.hasTeamProvider(), false, 'validate-then-commit covers the provider too')
+})
+
+// ── The call path: every failure shape becomes { ok: false } ───────────────
+
+test('no registered provider is a refusal, not an empty answer', async () => {
+ const answer = await teamProvider.getTeams()
+ assert.equal(answer.ok, false)
+ assert.equal(answer.teams, undefined, 'a refusal never carries a teams array')
+ assert.equal(teamProvider.providerModuleId(), null)
+})
+
+test('a provider that throws is a refusal', async () => {
+ register('uo', { ...ok(), getTeams: async () => { throw new Error('sidecar unreachable') } })
+ const answer = await teamProvider.getTeams()
+ assert.equal(answer.ok, false)
+ assert.match(answer.reason, /sidecar unreachable/)
+ assert.equal(answer.teams, undefined)
+})
+
+test('a provider that throws SYNCHRONOUSLY is a refusal too', async () => {
+ register('uo', { ...ok(), getTeams: () => { throw new Error('boom') } })
+ const answer = await teamProvider.getTeams()
+ assert.equal(answer.ok, false)
+ assert.match(answer.reason, /boom/)
+})
+
+test('a deliberate { ok: false } keeps its reason for team_sync_state', async () => {
+ register('uo', { ...ok(), getTeams: async () => ({ ok: false, reason: 'cache cold' }) })
+ assert.deepEqual(await teamProvider.getTeams(), { ok: false, reason: 'cache cold' })
+})
+
+test('a missing ok field is not read as authority', async () => {
+ register('uo', { ...ok(), getTeams: async () => ({ teams: [] }) })
+ const answer = await teamProvider.getTeams()
+ assert.equal(answer.ok, false, 'a forgotten field must not become an authoritative empty list')
+})
+
+test('a bare array — the shape the envelope exists to outlaw — is a refusal', async () => {
+ register('uo', { ...ok(), getTeams: async () => [] })
+ const answer = await teamProvider.getTeams()
+ assert.equal(answer.ok, false)
+ assert.match(answer.reason, /not an envelope/)
+})
+
+test('null, undefined and a string are all refusals', async () => {
+ for (const bad of [null, undefined, 'ok', 42]) {
+ register('uo', { ...ok(), getTeams: async () => bad })
+ // eslint-disable-next-line no-await-in-loop
+ assert.equal((await teamProvider.getTeams()).ok, false, `${String(bad)} must not be authoritative`)
+ registries._reset()
+ }
+})
+
+test('ok:true with no teams array is a refusal, not zero teams', async () => {
+ register('uo', { ...ok(), getTeams: async () => ({ ok: true }) })
+ const answer = await teamProvider.getTeams()
+ assert.equal(answer.ok, false)
+ assert.match(answer.reason, /no teams array/)
+})
+
+test('an ok answer with a genuinely empty list stays ok — §2.4 decides what to do with it', async () => {
+ register('uo', { ...ok(), getTeams: async () => ({ ok: true, teams: [] }) })
+ const answer = await teamProvider.getTeams()
+ assert.equal(answer.ok, true, 'this file must not second-guess an authoritative empty answer')
+ assert.deepEqual(answer.teams, [])
+})
+
+// ── Malformed rows fail the call rather than being salvaged ────────────────
+
+test('a team with no externalId fails the whole call', async () => {
+ register('uo', { ...ok(), getTeams: async () => ({ ok: true, teams: [{ name: 'Nameless' }] }) })
+ const answer = await teamProvider.getTeams()
+ assert.equal(answer.ok, false)
+ assert.match(answer.reason, /no externalId/)
+})
+
+test('a team with no name fails the whole call', async () => {
+ register('uo', { ...ok(), getTeams: async () => ({ ok: true, teams: [{ externalId: 'g1', name: ' ' }] }) })
+ assert.match((await teamProvider.getTeams()).reason, /"g1" has no name/)
+})
+
+test('one unreadable member refuses the roster rather than dropping the member', async () => {
+ // Dropping it would be indistinguishable, downstream, from the member leaving —
+ // the sync would mark them departed on the strength of a malformed payload.
+ register('uo', {
+ ...ok(),
+ getTeamMembers: async () => ({ ok: true, members: [{ memberKey: '0x1' }, { displayName: 'ghost' }] }),
+ })
+ const answer = await teamProvider.getTeamMembers('g1')
+ assert.equal(answer.ok, false)
+ assert.equal(answer.members, undefined)
+})
+
+test('a duplicated memberKey is refused rather than collapsed', async () => {
+ register('uo', {
+ ...ok(),
+ getTeamMembers: async () => ({ ok: true, members: [{ memberKey: '0x1' }, { memberKey: '0x1' }] }),
+ })
+ assert.match((await teamProvider.getTeamMembers('g1')).reason, /appears twice/)
+})
+
+// ── Normalisation of a good answer ─────────────────────────────────────────
+
+test('team fields are trimmed, and meta is passed through opaquely', async () => {
+ register('uo', {
+ ...ok(),
+ getTeams: async () => ({
+ ok: true,
+ teams: [{ externalId: ' g1 ', name: ' The Silver Hand ', abbr: ' TSH ', meta: { crest: 7 } }],
+ }),
+ })
+ const { teams } = await teamProvider.getTeams()
+ assert.deepEqual(teams, [{ externalId: 'g1', name: 'The Silver Hand', abbr: 'TSH', meta: { crest: 7 } }])
+})
+
+test('a non-object meta is dropped rather than stored as a scalar', async () => {
+ register('uo', {
+ ...ok(),
+ getTeams: async () => ({ ok: true, teams: [{ externalId: 'g1', name: 'X', meta: 'crest' }] }),
+ })
+ assert.equal((await teamProvider.getTeams()).teams[0].meta, null)
+})
+
+test('member booleans are coerced and userId is accepted only as a positive integer', async () => {
+ register('uo', {
+ ...ok(),
+ getTeamMembers: async () => ({
+ ok: true,
+ members: [
+ { memberKey: '0x1', displayName: 'Aldric', rankLabel: 'Warlord', leader: 1, online: 'yes', userId: 7 },
+ { memberKey: '0x2', userId: 0 },
+ { memberKey: '0x3', userId: '7' },
+ { memberKey: '0x4', userId: 1.5 },
+ ],
+ }),
+ })
+ const { members } = await teamProvider.getTeamMembers('g1')
+ assert.equal(members[0].leader, true)
+ assert.equal(members[0].online, true)
+ assert.equal(members[0].userId, 7)
+ assert.equal(members[1].userId, null, '0 is not a user id')
+ assert.equal(members[2].userId, null, 'a numeric string is not a resolved link')
+ assert.equal(members[3].userId, null)
+ // Absent optional fields become null rather than undefined, so a column write
+ // does not depend on the module having spelled the key.
+ assert.equal(members[1].displayName, null)
+ assert.equal(members[1].rankLabel, null)
+})
+
+test('complete defaults to true and is honoured when false', async () => {
+ register('uo', ok())
+ assert.equal((await teamProvider.getTeams()).complete, true)
+ registries._reset()
+
+ register('uo', { ...ok(), getTeams: async () => ({ ok: true, complete: false, teams: [] }) })
+ assert.equal((await teamProvider.getTeams()).complete, false)
+})
+
+test('duplicate leaders are collapsed and blanks refused', async () => {
+ register('uo', { ...ok(), getTeamLeaders: async () => ({ ok: true, leaders: ['0x1', '0x1', ' 0x2 '] }) })
+ assert.deepEqual((await teamProvider.getTeamLeaders('g1')).leaders, ['0x1', '0x2'])
+ registries._reset()
+
+ register('uo', { ...ok(), getTeamLeaders: async () => ({ ok: true, leaders: ['0x1', ''] }) })
+ assert.equal((await teamProvider.getTeamLeaders('g1')).ok, false)
+})
+
+test('the external id is passed through to the module unchanged', async () => {
+ const seen = []
+ register('uo', { ...ok(), getTeamMembers: async (id) => { seen.push(id); return { ok: true, members: [] } } })
+ await teamProvider.getTeamMembers('g-42')
+ assert.deepEqual(seen, ['g-42'])
+})
+
+test('providerModuleId names the registrant, which is what sync state is keyed on', async () => {
+ register('uo', ok())
+ assert.equal(teamProvider.providerModuleId(), 'uo')
+})
+
+// ── The timeout ────────────────────────────────────────────────────────────
+
+test('a provider that never answers becomes a refusal at the deadline', async (t) => {
+ // Mocked timers rather than a real ten-second wait: this exercises the
+ // production path exactly — the same setTimeout, the same deadline — without
+ // putting ten seconds into every CI run.
+ t.mock.timers.enable({ apis: ['setTimeout'] })
+ register('uo', { ...ok(), getTeams: () => new Promise(() => {}) })
+
+ const pending = teamProvider.getTeams()
+ t.mock.timers.tick(teamProvider.CALL_TIMEOUT_MS)
+
+ const answer = await pending
+ assert.equal(answer.ok, false)
+ assert.match(answer.reason, /did not answer within 10000ms/)
+ assert.equal(answer.teams, undefined, 'a hung module never produces data')
+})
+
+test('a hung call does not hold the process open until its deadline', async () => {
+ // The timer is unreffed, so a call left pending at shutdown cannot keep the
+ // event loop alive. Asserted directly, because the symptom — a test FILE that
+ // passes in milliseconds and then sits for ten seconds — is invisible in a
+ // green summary.
+ register('uo', { ...ok(), getTeams: () => new Promise(() => {}) })
+ const before = process.getActiveResourcesInfo().filter((r) => r === 'Timeout').length
+ teamProvider.getTeams()
+ await new Promise((resolve) => { setImmediate(resolve) })
+ const after = process.getActiveResourcesInfo().filter((r) => r === 'Timeout').length
+ assert.equal(after, before, 'the deadline timer must not count as an active resource')
+})
+
+test('the budget is the documented ten seconds', () => {
+ assert.equal(teamProvider.CALL_TIMEOUT_MS, 10_000)
+})
--
2.49.1
From 92631347f949fc154f310ff0cc895680b8eae111 Mon Sep 17 00:00:00 2001
From: wtclaude
Date: Mon, 17 Aug 2026 14:53:52 -0500
Subject: [PATCH 03/35] feat(teams): the reconciler, its four refusal gates,
and ctx.teams (API 1.6.0)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Core's projection of the module's Teams, kept in step (docs/website/TEAMS.md
§2.4), plus the two ctx members a module pushes through.
The four gates are the file, and each is invariant 1 in a different costume --
module unavailability is staleness, never emptiness:
1. getTeams() not ok -> record the failure, touch NOTHING, return.
2. ok but empty, core holds >=1 -> quarantine; apply only if the NEXT
authoritative answer, an interval later,
agrees.
3. getTeamMembers() not ok -> that Team's roster untouched and stale; the
other Teams sync normally.
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 to confirm; getting it wrong empties
every roster on the site.
Events are an optimisation, never the source of truth. Member and leadership
deltas apply at once for a Team core already knows; team.created and
team.disbanded only ask for a run. §2.2 scopes archival to an authoritative full
list, so a repeated or spurious disband event costs a reconcile rather than a
Team -- and a Team invented from a delta would have no name, no roster and no
leaders anyway.
Two columns TEAMS.md did not contemplate, both on `teams`:
- roster_synced_at, because team_sync_state holds one row per MODULE and gate 3
leaves ONE Team behind while the others sync. Without a per-Team stamp that
Team's page would report the module's last success as its own -- exactly the
staleness the gate exists to surface.
- members_empty_since, gate 4's per-Team quarantine. The twin of
team_sync_state.pending_empty_since, which is per module and cannot express it.
One real bug found by its own test. The roster upsert was writing is_leader, so a
refused getTeamLeaders() left every member demoted -- the roster had already
written `leader: false` before the authoritative call was even made. §2.5 is
explicit that path 2 is answered by getTeamLeaders(), so is_leader is now set on
INSERT only (seeding a Team so it is not leaderless while that call fails) and
moved afterwards by setLeaders() alone. Two writers for one column was the whole
defect.
MODULE_API_VERSION 1.6.0 on both halves -- they state one contract and a module
declares one coreApi range. The number covers the whole Team surface per Part 11;
the members arrive by phase. registerTeamProvider, ctx.teams.publish and
ctx.teams.reconcile are live. ctx.teams.activity.push (§4, phase 3) and
api.registerSlashCommands (§7.1, phase 7) are present and THROW with a sentence
naming their phase, rather than being absent or silently accepting data into
tables that do not exist yet.
39 tests here, and the ctx surface guard in moduleLoader.test.js updated -- it
caught the addition, which is what it is for. Server 809 passed, client 192
passed, 0 failed.
Refs docs/website/TEAMS.md §2.2, §2.3, §2.4, Part 11, Part 12 phase 2
Co-Authored-By: Claude
---
client/src/modules/version.js | 9 +-
server/db/schema.sql | 10 +
server/src/model/teams/teamSlug.js | 50 ++
server/src/model/teams/teamSync.model.js | 482 +++++++++++++++
server/src/model/teams/teams.db.js | 297 +++++++++
server/src/modules/lifecycle.js | 9 +
server/src/modules/loader.js | 34 ++
server/src/modules/version.js | 17 +-
server/test/moduleLoader.test.js | 6 +-
server/test/teamSync.test.js | 739 +++++++++++++++++++++++
10 files changed, 1650 insertions(+), 3 deletions(-)
create mode 100644 server/src/model/teams/teamSlug.js
create mode 100644 server/src/model/teams/teamSync.model.js
create mode 100644 server/src/model/teams/teams.db.js
create mode 100644 server/test/teamSync.test.js
diff --git a/client/src/modules/version.js b/client/src/modules/version.js
index 73934de..c0c833a 100644
--- a/client/src/modules/version.js
+++ b/client/src/modules/version.js
@@ -11,6 +11,13 @@
// that the two files can drift, so a test asserts they agree
// (client/test/moduleRegistry.test.js) rather than trusting a bump to remember
// both.
+// 1.6.0 — the Team surface (docs/website/TEAMS.md Part 11). Nothing on this half
+// changed yet: the two client additions the version covers are the `team.overview`
+// and `team.member.row` slots, and a slot can only be declared by the page that
+// hosts it, which lands with the Team pages in phase 3. This file bumps anyway,
+// for the reason at the top — the two halves state ONE version, and a module
+// declares one `coreApi` range against both.
+//
// 1.5.0 — `PublicLayout` takes an optional `shell` prop ('narrow' | 'mid' |
// 'wide') that renders the `shell-… page-body` wrapper core's own pages write by
// hand. Additive: omitting it is 1.4.0's behaviour, so §3.4's "changing a kit
@@ -38,4 +45,4 @@
// but the two halves state ONE version: a module declares a single coreApi range
// and is served one chunk, so a client that claimed 1.0.0 while the server
// answered 1.1.0 would be two answers to one question.
-export const MODULE_API_VERSION = '1.5.0'
+export const MODULE_API_VERSION = '1.6.0'
diff --git a/server/db/schema.sql b/server/db/schema.sql
index f12fc68..2808561 100644
--- a/server/db/schema.sql
+++ b/server/db/schema.sql
@@ -885,6 +885,16 @@ CREATE TABLE IF NOT EXISTS teams (
-- 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
diff --git a/server/src/model/teams/teamSlug.js b/server/src/model/teams/teamSlug.js
new file mode 100644
index 0000000..da1eb47
--- /dev/null
+++ b/server/src/model/teams/teamSlug.js
@@ -0,0 +1,50 @@
+// Deriving a Team's URL slug from a game-written name (TEAMS.md §2.1).
+//
+// A slug is derived ONCE, at create, and then frozen for the life of the row —
+// like `name`, and for the same reason: the Team page URL has to stay stable, and
+// a rename is an archive plus a create rather than an edit.
+
+const MAX_SLUG = 180 // the column is 191; leaves room for a -NN suffix
+
+/**
+ * Reduce a name to a URL-safe stem.
+ *
+ * Diacritics are folded rather than stripped so "Ünderdark" becomes "underdark"
+ * and not "nderdark". A name made entirely of characters that do not survive —
+ * which a guild name genuinely can be, since the game accepts far more than a URL
+ * does — leaves an empty stem, and the caller substitutes a stable fallback
+ * rather than minting a Team with no address.
+ */
+function slugify(name) {
+ return String(name || '')
+ .normalize('NFKD')
+ .replace(/[̀-ͯ]/g, '')
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '')
+ .slice(0, MAX_SLUG)
+ .replace(/-+$/g, '')
+}
+
+/**
+ * A slug not already taken, given the ones that are.
+ *
+ * `taken` must include ARCHIVED teams' slugs, not only active ones. The unique
+ * key constrains active rows alone, so the database would allow a new Team to
+ * take a retired Team's slug — and §2.2 promises the retired one stays readable
+ * at that address, which is what a bookmark or an old Discord link resolves to.
+ */
+function uniqueSlug(name, taken, { fallback = 'team' } = {}) {
+ const base = slugify(name) || fallback
+ const used = new Set(taken)
+ if (!used.has(base)) return base
+ // Bounded rather than unbounded: a suffix search that cannot terminate is worse
+ // than a slug with an id in it, and 999 same-named teams is already absurd.
+ for (let n = 2; n <= 999; n++) {
+ const candidate = `${base}-${n}`
+ if (!used.has(candidate)) return candidate
+ }
+ return `${base}-${Date.now().toString(36)}`
+}
+
+module.exports = { slugify, uniqueSlug, MAX_SLUG }
diff --git a/server/src/model/teams/teamSync.model.js b/server/src/model/teams/teamSync.model.js
new file mode 100644
index 0000000..4d5f764
--- /dev/null
+++ b/server/src/model/teams/teamSync.model.js
@@ -0,0 +1,482 @@
+// ── 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 { 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.
+ *
+ * Screening the name against the reserved list happens here in a later commit;
+ * the row is created either way, because core cannot refuse a name — the guild
+ * already exists in the game and core is a mirror of it, not an authority over it.
+ */
+async function createTeam(moduleId, team) {
+ const taken = await teamsDb.slugsLike(slugify(team.name) || 'team')
+ const slug = uniqueSlug(team.name, taken)
+ const id = await teamsDb.insertTeam({ moduleId, slug, ...team })
+ log.info('team created', { moduleId, externalId: team.externalId, name: team.name, slug })
+ return id
+}
+
+/**
+ * The §2.2 rename rule: same id and a different name is an archive plus a create.
+ *
+ * The old row keeps its forum, its activity and its grants, all read-only, and
+ * points at its successor so the old slug can explain itself instead of 404ing.
+ * Core never decides whether this is "really" the same team — that judgement is
+ * the module's, expressed in whether it reuses the external id (§10.5).
+ */
+async function applyRename(moduleId, existing, team) {
+ const successorId = await createTeam(moduleId, team)
+ await teamsDb.archiveTeam(existing.id, 'renamed', successorId)
+ log.info('team renamed; previous row archived', {
+ externalId: team.externalId, from: existing.name, to: team.name, archivedId: existing.id, successorId,
+ })
+ return successorId
+}
+
+/**
+ * Sync one Team's roster and leadership. Gates 3 and 4 live here.
+ *
+ * Returns whether the roster was applied, so the caller can tell "synced" from
+ * "left alone", which is the difference between fresh and stale on that Team's
+ * page.
+ */
+async function syncRoster(team) {
+ const answer = await teamProvider.getTeamMembers(team.external_id)
+
+ // Gate 3. One Team's unanswerable roster is not the other Teams' problem, and
+ // it is certainly not an empty roster.
+ if (!answer.ok) {
+ log.warn('roster left untouched; provider could not answer', {
+ externalId: team.external_id, reason: answer.reason,
+ })
+ return false
+ }
+
+ const known = await teamsDb.memberKeys(team.id)
+
+ // Gate 4, the per-Team twin of gate 2.
+ if (answer.complete && answer.members.length === 0 && known.length > 0) {
+ if (!team.members_empty_since) {
+ await teamsDb.setMembersEmptySince(team.id, new Date())
+ log.warn('empty roster quarantined; awaiting a second answer', {
+ externalId: team.external_id, had: known.length,
+ })
+ return false
+ }
+ log.warn('empty roster confirmed by a second answer; departing every member', {
+ externalId: team.external_id, had: known.length,
+ })
+ } else if (team.members_empty_since) {
+ // Any non-empty answer clears the quarantine.
+ await teamsDb.setMembersEmptySince(team.id, null)
+ }
+
+ for (const member of answer.members) {
+ // eslint-disable-next-line no-await-in-loop
+ await teamsDb.upsertMember({
+ teamId: team.id,
+ memberKey: member.memberKey,
+ displayName: member.displayName,
+ userId: member.userId,
+ isLeader: member.leader,
+ rankLabel: member.rankLabel,
+ online: member.online,
+ })
+ }
+
+ // Removals only from a COMPLETE answer. `complete: false` means "valid but
+ // partial", so additions and updates apply and nothing is taken away.
+ if (answer.complete) {
+ const seen = new Set(answer.members.map((m) => m.memberKey))
+ await teamsDb.markDeparted(team.id, known.filter((key) => !seen.has(key)))
+ }
+
+ // Leadership is a separate question with a separate answer, and a provider that
+ // cannot answer it leaves the synced value alone rather than demoting everyone.
+ const leaders = await teamProvider.getTeamLeaders(team.external_id)
+ if (leaders.ok) {
+ await teamsDb.setLeaders(team.id, leaders.leaders)
+ } else {
+ log.warn('leadership left untouched; provider could not answer', {
+ externalId: team.external_id, reason: leaders.reason,
+ })
+ }
+
+ await teamsDb.recount(team.id)
+ await teamsDb.markRosterSynced(team.id)
+ return true
+}
+
+// ── The run ────────────────────────────────────────────────────────────────
+
+/**
+ * One full reconciliation. Callers use `request()`; this is the body it guards.
+ *
+ * Never throws: a reconcile is a background job, and a rejection here would
+ * surface as an unhandled rejection in the poll timer rather than as anything an
+ * operator could act on. The failure is recorded where it can be read — in
+ * `team_sync_state`, which Admin → Teams shows verbatim.
+ */
+async function runOnce(reason) {
+ const moduleId = teamProvider.providerModuleId()
+ if (!moduleId) return { ok: false, reason: 'no team provider is registered' }
+
+ await teamsDb.recordAttempt(moduleId)
+ const answer = await teamProvider.getTeams()
+
+ // Gate 1.
+ if (!answer.ok) {
+ await teamsDb.recordFailure(moduleId, answer.reason)
+ log.warn('reconcile refused; provider could not answer', { reason: answer.reason, trigger: reason })
+ return { ok: false, reason: answer.reason }
+ }
+
+ const existing = await teamsDb.activeByModule(moduleId)
+
+ // Gate 2. Only a COMPLETE answer can mean "there are no teams" — a partial one
+ // removes nothing by definition.
+ if (answer.complete && answer.teams.length === 0 && existing.length > 0) {
+ const state = await teamsDb.syncState(moduleId)
+ if (!state || !state.pending_empty_since) {
+ await teamsDb.setPendingEmpty(moduleId, new Date())
+ await teamsDb.recordSuccess(moduleId)
+ log.warn('empty team list quarantined; awaiting a second answer', { held: existing.length })
+ return { ok: true, quarantined: true, applied: 0 }
+ }
+ const intervalS = await intervalSeconds()
+ const waited = (Date.now() - new Date(state.pending_empty_since).getTime()) / 1000
+ if (waited < intervalS) {
+ await teamsDb.recordSuccess(moduleId)
+ log.warn('empty team list still quarantined', { waitedSeconds: Math.round(waited), intervalS })
+ return { ok: true, quarantined: true, applied: 0 }
+ }
+ log.warn('empty team list confirmed; archiving every active team', { count: existing.length })
+ } else if (answer.teams.length) {
+ // Any non-empty answer clears the quarantine.
+ await teamsDb.setPendingEmpty(moduleId, null)
+ }
+
+ const byExternalId = new Map(existing.map((t) => [t.external_id, t]))
+ const seen = new Set()
+ let created = 0
+ let renamed = 0
+ let rosters = 0
+
+ for (const team of answer.teams) {
+ seen.add(team.externalId)
+ const current = byExternalId.get(team.externalId)
+ let id
+ if (!current) {
+ id = await createTeam(moduleId, team)
+ created += 1
+ } else if (current.name !== team.name) {
+ id = await applyRename(moduleId, current, team)
+ renamed += 1
+ } else {
+ id = current.id
+ await teamsDb.updateTeam(id, { abbr: team.abbr, meta: team.meta })
+ }
+
+ // Re-read rather than reusing `current`: a create or a rename has just made a
+ // row this loop has never seen, and syncRoster reads the quarantine stamp off
+ // it. Passing a stale object would drop the second strike of gate 4.
+ const row = await teamsDb.findById(id)
+ if (row && await syncRoster(row)) rosters += 1
+ }
+
+ // Archive what the module no longer lists — the §2.2 disband path, and the only
+ // one. Guarded by `complete` for the same reason removals are.
+ let archived = 0
+ if (answer.complete) {
+ for (const team of existing) {
+ if (seen.has(team.external_id)) continue
+ await teamsDb.archiveTeam(team.id, 'disbanded')
+ archived += 1
+ log.info('team archived; absent from an authoritative list', {
+ externalId: team.external_id, name: team.name,
+ })
+ }
+ }
+
+ await teamsDb.recordSuccess(moduleId)
+ log.info('reconcile complete', { trigger: reason, created, renamed, archived, rosters, total: answer.teams.length })
+ return { ok: true, created, renamed, archived, rosters }
+}
+
+// ── 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,
+}
diff --git a/server/src/model/teams/teams.db.js b/server/src/model/teams/teams.db.js
new file mode 100644
index 0000000..f7f3c27
--- /dev/null
+++ b/server/src/model/teams/teams.db.js
@@ -0,0 +1,297 @@
+// 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],
+ )
+}
+
+/** 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,
+ findActive,
+ findById,
+ findBySlug,
+ slugsLike,
+ insertTeam,
+ updateTeam,
+ archiveTeam,
+ recount,
+ markRosterSynced,
+ setMembersEmptySince,
+ membersByTeam,
+ memberKeys,
+ findMember,
+ activeByUser,
+ activeTeamsForUser,
+ upsertMember,
+ markDeparted,
+ setLeaders,
+ setMemberLeader,
+ syncState,
+ recordAttempt,
+ recordFailure,
+ recordSuccess,
+ setPendingEmpty,
+}
diff --git a/server/src/modules/lifecycle.js b/server/src/modules/lifecycle.js
index 637df88..3e23a3d 100644
--- a/server/src/modules/lifecycle.js
+++ b/server/src/modules/lifecycle.js
@@ -170,6 +170,15 @@ async function boot({ modules, model } = {}) {
})
}
}
+
+ // The Team reconciler's boot trigger (TEAMS.md §2.4), last — after every module
+ // has started, because the provider is registered by a module and a module that
+ // warms a cache in onBoot must be allowed to finish before it is asked anything.
+ //
+ // `safe` for the same reason every step above uses it: an unreachable provider
+ // is a stale projection, never a site that will not start.
+ // eslint-disable-next-line global-require
+ await safe('starting the team reconciler', () => require('../model/teams/teamSync.model').start())
}
/** Reject if `fn`'s promise has not settled within `ms`. */
diff --git a/server/src/modules/loader.js b/server/src/modules/loader.js
index 51faad2..c1d4370 100644
--- a/server/src/modules/loader.js
+++ b/server/src/modules/loader.js
@@ -119,6 +119,7 @@ function buildCtx(id, moduleRoot) {
const uploads = require('../router/v1/admin/imageUpload')
const activity = require('../model/activity/activity.model')
const users = require('../model/users/users.model')
+ const teams = require('../model/teams/teamSync.model')
const { makeLimiter, accountChangeLimiter } = require('../middleware/rateLimit')
/* eslint-enable global-require */
@@ -174,6 +175,31 @@ function buildCtx(id, moduleRoot) {
// a place nobody looks. `list` stays core's: reading the log is the admin
// panel's job, and it spans every actor.
activity: { log: activity.log },
+ // Teams (API 1.6.0, TEAMS.md §2.3). Push, to the pull the provider answers.
+ //
+ // Both are fire-and-forget by contract. `publish` is an OPTIMISATION — it
+ // makes a membership change visible at once — and `reconcile` is a REQUEST,
+ // debounced and never awaited, so a module cannot make its own call site slow
+ // or turn a background failure into its own error. Correctness comes from the
+ // reconciler either way; these only decide how soon.
+ //
+ // There is deliberately no reader here. A module answers questions about
+ // Teams; it does not ask them. Every Team table is core-internal (§10.3), and
+ // a `getTeamRoster` on ctx would be core offering to read back the module's
+ // own answer — which is the module's data, in the module's own store.
+ teams: {
+ publish: (event) => teams.publish(event),
+ reconcile: (opts) => teams.request(opts),
+ // §4's activity feed, which lands with the Team pages in phase 3. Declared
+ // in 1.6.0 alongside the rest of the Team surface; calling it before phase 3
+ // throws rather than silently accepting items into a table that does not
+ // exist yet.
+ activity: {
+ push: () => {
+ throw new Error('ctx.teams.activity.push is not available until the Team activity feed lands (TEAMS.md §4)')
+ },
+ },
+ },
// One function, for one caller: the `admin.users.detail` slot router needs
// the user its prefix names. Narrowed like `ctx.posts` — the users model
// exports creation, role changes and password handling, none of which is a
@@ -249,6 +275,14 @@ function buildApi(record) {
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
diff --git a/server/src/modules/version.js b/server/src/modules/version.js
index 6ce1362..b7ef857 100644
--- a/server/src/modules/version.js
+++ b/server/src/modules/version.js
@@ -9,6 +9,21 @@
// Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and
// has nothing to say about a website module) and from any module's own version.
+// 1.6.0 — the Team surface (docs/website/TEAMS.md Part 11). Additions only, so
+// minor: `api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders })`,
+// `ctx.teams.publish(event)`, `ctx.teams.reconcile({ reason })`,
+// `ctx.teams.activity.push(items)`, `api.registerSlashCommands([...])`, and the
+// client slots `team.overview` / `team.member.row`. module-uo's `coreApi:
+// "^1.3.0"` still resolves.
+//
+// **The number covers the whole surface; the members arrive by phase.** The three
+// this phase implements are live. `activity.push` lands with the Team activity
+// feed (§4, phase 3) and `registerSlashCommands` with the Discord commands (§7.1,
+// phase 7) — until then each is present and THROWS rather than being absent or,
+// worse, silently accepting data into a table that does not exist. MODULE_API.md
+// names the phase against each member, so a module author reads what is callable
+// today rather than discovering it at runtime.
+//
// 1.5.0 — a CLIENT addition: `PublicLayout` takes an optional `shell` prop that
// renders the page body wrapper core's own pages write by hand (MODULE_API.md
// §3.4). Minor, not major: §3.4 makes *changing* a kit component's props a major
@@ -44,6 +59,6 @@
// an admin action a module performs belongs in core's one audit log, the
// extension slot needs the user its prefix names, and §2.7 forbids a module
// reading core's `APP_BASE_URL` for itself. Additions only, so minor.
-const MODULE_API_VERSION = '1.5.0'
+const MODULE_API_VERSION = '1.6.0'
module.exports = { MODULE_API_VERSION }
diff --git a/server/test/moduleLoader.test.js b/server/test/moduleLoader.test.js
index c0ebe3a..cef7339 100644
--- a/server/test/moduleLoader.test.js
+++ b/server/test/moduleLoader.test.js
@@ -507,9 +507,13 @@ test('ctx exposes exactly the documented surface, and is frozen', () => {
// extraction needed it and none could be vendored: an admin action a module
// performs belongs in core's one audit log, the extension slot needs the user
// its prefix names, and §2.7 forbids a module reading core's APP_BASE_URL.
+ // API 1.6.0 added `teams` — push, to the pull the team provider answers
+ // (TEAMS.md §2.3). Read-only by omission: a module answers questions about
+ // Teams and never asks them, so there is no getter here to add later by
+ // accident.
assert.deepEqual(probe.keys, [
'activity', 'auth', 'db', 'express', 'log', 'middleware', 'moduleId', 'paths',
- 'posts', 'push', 'secretBox', 'settings', 'site', 'uploads', 'users', 'validator',
+ 'posts', 'push', 'secretBox', 'settings', 'site', 'teams', 'uploads', 'users', 'validator',
])
// is core's limiter FACTORY, not a limiter: a module states its own
// window and cap and takes the plumbing, so there is one express-rate-limit in
diff --git a/server/test/teamSync.test.js b/server/test/teamSync.test.js
new file mode 100644
index 0000000..b2cdfe4
--- /dev/null
+++ b/server/test/teamSync.test.js
@@ -0,0 +1,739 @@
+// 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 settings = require('../src/model/settings/settings.model')
+const teamSync = require('../src/model/teams/teamSync.model')
+
+// ── An in-memory stand-in for the four tables ──────────────────────────────
+
+let store
+const saved = new Map()
+
+function patch(mod, name, fn) {
+ if (!saved.has(mod)) saved.set(mod, new Map())
+ if (!saved.get(mod).has(name)) saved.get(mod).set(name, mod[name])
+ mod[name] = fn
+}
+
+function restore() {
+ for (const [mod, names] of saved) for (const [name, fn] of names) mod[name] = fn
+ saved.clear()
+}
+
+function freshStore() {
+ return {
+ teams: [], // { id, module_id, external_id, name, abbr, slug, status, ... }
+ members: new Map(), // teamId -> Map(memberKey -> row)
+ sync: new Map(), // moduleId -> row
+ nextId: 1,
+ }
+}
+
+function membersOf(teamId) {
+ if (!store.members.has(teamId)) store.members.set(teamId, new Map())
+ return store.members.get(teamId)
+}
+
+function stubDb() {
+ patch(teamsDb, 'activeByModule', async (moduleId) =>
+ store.teams.filter((t) => t.module_id === moduleId && t.status === 'active'))
+
+ patch(teamsDb, 'findActive', async (moduleId, externalId) =>
+ store.teams.find((t) => t.module_id === moduleId && t.external_id === externalId && t.status === 'active'))
+
+ patch(teamsDb, 'findById', async (id) => store.teams.find((t) => t.id === id))
+
+ patch(teamsDb, 'slugsLike', async (base) =>
+ store.teams.filter((t) => t.slug === base || t.slug.startsWith(`${base}-`)).map((t) => t.slug))
+
+ patch(teamsDb, 'insertTeam', async (row) => {
+ const id = store.nextId++
+ store.teams.push({
+ id,
+ module_id: row.moduleId,
+ external_id: row.externalId,
+ name: row.name,
+ abbr: row.abbr ?? null,
+ slug: row.slug,
+ meta: row.meta ?? null,
+ status: 'active',
+ hidden: row.hidden ? 1 : 0,
+ hidden_reason: row.hiddenReason || null,
+ hidden_term: row.hiddenTerm || null,
+ members_empty_since: null,
+ roster_synced_at: null,
+ succeeded_by: null,
+ member_count: 0,
+ linked_count: 0,
+ online_count: 0,
+ })
+ return id
+ })
+
+ patch(teamsDb, 'updateTeam', async (id, { abbr, meta }) => {
+ const t = store.teams.find((x) => x.id === id)
+ if (t) Object.assign(t, { abbr, meta })
+ })
+
+ patch(teamsDb, 'archiveTeam', async (id, reason, succeededBy = null) => {
+ const t = store.teams.find((x) => x.id === id && x.status === 'active')
+ if (t) Object.assign(t, { status: 'archived', archived_reason: reason, succeeded_by: succeededBy })
+ })
+
+ patch(teamsDb, 'recount', async (teamId) => {
+ const t = store.teams.find((x) => x.id === teamId)
+ if (!t) return
+ const rows = [...membersOf(teamId).values()].filter((m) => m.status === 'active')
+ t.member_count = rows.length
+ t.linked_count = rows.filter((m) => m.user_id != null).length
+ t.online_count = rows.filter((m) => m.online).length
+ })
+
+ patch(teamsDb, 'markRosterSynced', async (teamId) => {
+ const t = store.teams.find((x) => x.id === teamId)
+ if (t) t.roster_synced_at = new Date()
+ })
+
+ patch(teamsDb, 'setMembersEmptySince', async (teamId, since) => {
+ const t = store.teams.find((x) => x.id === teamId)
+ if (t) t.members_empty_since = since
+ })
+
+ patch(teamsDb, 'memberKeys', async (teamId) =>
+ [...membersOf(teamId).values()].filter((m) => m.status === 'active').map((m) => m.member_key))
+
+ patch(teamsDb, 'upsertMember', async (m) => {
+ const existing = membersOf(m.teamId).get(m.memberKey)
+ membersOf(m.teamId).set(m.memberKey, {
+ team_id: m.teamId,
+ member_key: m.memberKey,
+ display_name: m.displayName ?? null,
+ user_id: m.userId ?? null,
+ // Insert-only, mirroring the ON DUPLICATE KEY UPDATE clause that omits it:
+ // leadership is getTeamLeaders()'s answer, not the roster's.
+ is_leader: existing ? existing.is_leader : (m.isLeader ? 1 : 0),
+ rank_label: m.rankLabel ?? null,
+ online: m.online ? 1 : 0,
+ status: 'active',
+ first_seen_at: existing ? existing.first_seen_at : 'first',
+ })
+ })
+
+ patch(teamsDb, 'markDeparted', async (teamId, keys) => {
+ for (const key of keys) {
+ const row = membersOf(teamId).get(key)
+ if (row && row.status === 'active') Object.assign(row, { status: 'departed', online: 0 })
+ }
+ })
+
+ patch(teamsDb, 'setLeaders', async (teamId, leaderKeys) => {
+ for (const row of membersOf(teamId).values()) row.is_leader = leaderKeys.includes(row.member_key) ? 1 : 0
+ })
+
+ patch(teamsDb, 'setMemberLeader', async (teamId, key, isLeader) => {
+ const row = membersOf(teamId).get(key)
+ if (row) row.is_leader = isLeader ? 1 : 0
+ })
+
+ patch(teamsDb, 'syncState', async (moduleId) => store.sync.get(moduleId))
+ patch(teamsDb, 'recordAttempt', async (moduleId) => {
+ const s = store.sync.get(moduleId) || { module_id: moduleId, consecutive_failures: 0 }
+ s.last_attempt_at = new Date()
+ store.sync.set(moduleId, s)
+ })
+ patch(teamsDb, 'recordFailure', async (moduleId, error) => {
+ const s = store.sync.get(moduleId) || { module_id: moduleId, consecutive_failures: 0 }
+ s.consecutive_failures += 1
+ s.last_error = error
+ store.sync.set(moduleId, s)
+ })
+ patch(teamsDb, 'recordSuccess', async (moduleId) => {
+ const s = store.sync.get(moduleId) || { module_id: moduleId, consecutive_failures: 0 }
+ s.consecutive_failures = 0
+ s.last_error = null
+ s.last_success_at = new Date()
+ store.sync.set(moduleId, s)
+ })
+ patch(teamsDb, 'setPendingEmpty', async (moduleId, since) => {
+ const s = store.sync.get(moduleId) || { module_id: moduleId, consecutive_failures: 0 }
+ s.pending_empty_since = since
+ store.sync.set(moduleId, s)
+ })
+}
+
+// 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')
+})
+
+// ── 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)
+})
--
2.49.1
From bfd844e8fb673850611620d433f5e6742ffb297d Mon Sep 17 00:00:00 2001
From: wtclaude
Date: Mon, 17 Aug 2026 14:57:23 -0500
Subject: [PATCH 04/35] feat(teams): the four-path access resolver and staff
leadership overrides
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The four authority paths of docs/website/TEAMS.md §2.5, and the rule that they
stay four: 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 + override
3. May it use the Team forum? CORE team_forum_grants OR path 1
4. May it get external access? CORE derived, nothing of its own
The temptation this resists 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 a forum must not require a
staff ticket. Reading "has forum access" as "is a member" would put that person
on the public roster, into every membership count, and into the external-platform
grant, which is where a modelling preference becomes an impersonation risk.
Path 4 is deliberately blind to path 3, and the reason is written down so nobody
"fixes" it: 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.
Leadership overrides are applied ON TOP of the synced value at read time, never
written into the projection. The sync owns that column and rewrites it every
interval, so an override stored there would be undone fifteen minutes after
staff set it -- which is the whole reason §2.5.1 is a separate table. The roster
carries both the resolved answer and `is_leader_synced`, so an admin sees that a
decision was made rather than being shown it as fact.
Three tests are named INVARIANT rather than for behaviour, because what they
protect is structural and a reasonable-looking refactor destroys it silently: a
grant never writes the membership projection, a granted user is absent from the
roster, and a grant does not confer external eligibility. None of those failures
appears on a screen as a bug -- 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.
Every unit test here stubs the db layer, so the SQL itself was verified
separately: all 44 statements across teams.db.js and teamAccess.db.js were run
against MariaDB 11 with a throwaway module id and cleaned up after. That run
also confirmed live what the reconciler's tests could only assert against a
stub -- an upsert does not overwrite is_leader, a revoked grant frees the unique
key for a new one while the ledger keeps both, and an archived team stays
resolvable at its old slug while its external_id is free for the successor row.
19 tests. Full suite 828 passed, 0 failed.
Refs docs/website/TEAMS.md §2.5, §2.5.1, §2.6, Part 12 phase 2
Co-Authored-By: Claude
---
server/src/model/teams/teamAccess.db.js | 94 +++++++++
server/src/model/teams/teamAccess.model.js | 131 +++++++++++++
server/test/teamAccess.test.js | 209 +++++++++++++++++++++
3 files changed, 434 insertions(+)
create mode 100644 server/src/model/teams/teamAccess.db.js
create mode 100644 server/src/model/teams/teamAccess.model.js
create mode 100644 server/test/teamAccess.test.js
diff --git a/server/src/model/teams/teamAccess.db.js b/server/src/model/teams/teamAccess.db.js
new file mode 100644
index 0000000..e21fe9a
--- /dev/null
+++ b/server/src/model/teams/teamAccess.db.js
@@ -0,0 +1,94 @@
+// SQL for the two tables the access resolver reads: forum grants (path 3) and
+// staff leadership overrides (§2.5.1).
+//
+// Kept separate from teams.db.js on purpose. The four authority paths are four
+// tables answering four questions, and the single most important structural rule
+// in TEAMS.md is that no resolver reads another path's table — a file boundary is
+// a cheap way to make crossing one visible in a diff.
+
+const { query } = require('../../utils/db')
+
+// ── team_forum_grants (path 3) ─────────────────────────────────────────────
+
+const GRANT_COLUMNS = `
+ id, team_id, user_id, username, granted_by, granted_username, granted_at, reason,
+ revoked_by, revoked_username, revoked_at, revoke_reason`
+
+/** The caller's ACTIVE grant on a team, or undefined. At most one, by the unique key. */
+async function activeGrant(teamId, userId) {
+ const rows = await query(
+ `SELECT ${GRANT_COLUMNS} FROM team_forum_grants
+ WHERE team_id = ? AND user_id = ? AND revoked_at IS NULL`,
+ [teamId, userId],
+ )
+ return rows[0]
+}
+
+/** The whole ledger for a team, revoked rows included — the admin grant view. */
+async function grantLedger(teamId) {
+ return query(
+ `SELECT ${GRANT_COLUMNS} FROM team_forum_grants WHERE team_id = ? ORDER BY granted_at DESC, id DESC`,
+ [teamId],
+ )
+}
+
+/** Active grants only, for the "Forum guests" list and the per-team cap. */
+async function activeGrants(teamId) {
+ return query(
+ `SELECT ${GRANT_COLUMNS} FROM team_forum_grants WHERE team_id = ? AND revoked_at IS NULL
+ ORDER BY granted_at`,
+ [teamId],
+ )
+}
+
+// ── team_leader_overrides (§2.5.1) ─────────────────────────────────────────
+
+const OVERRIDE_COLUMNS = 'team_id, member_key, effect, actor_user_id, actor_username, reason, created_at'
+
+async function overridesForTeam(teamId) {
+ return query(`SELECT ${OVERRIDE_COLUMNS} FROM team_leader_overrides WHERE team_id = ? ORDER BY member_key`,
+ [teamId])
+}
+
+async function overrideFor(teamId, memberKey) {
+ const rows = await query(
+ `SELECT ${OVERRIDE_COLUMNS} FROM team_leader_overrides WHERE team_id = ? AND member_key = ?`,
+ [teamId, memberKey],
+ )
+ return rows[0]
+}
+
+/**
+ * Set or replace one override.
+ *
+ * The projection is never touched by this — `team_members.is_leader` keeps saying
+ * what the game says and this keeps saying what staff decided, which is the entire
+ * point (§2.5.1). An override applied INTO the projection would be clobbered by
+ * the next sync, fifteen minutes later.
+ */
+async function setOverride({ teamId, memberKey, effect, actorUserId, actorUsername, reason }) {
+ await query(
+ `INSERT INTO team_leader_overrides (team_id, member_key, effect, actor_user_id, actor_username, reason)
+ VALUES (?, ?, ?, ?, ?, ?)
+ ON DUPLICATE KEY UPDATE
+ effect = VALUES(effect), actor_user_id = VALUES(actor_user_id),
+ actor_username = VALUES(actor_username), reason = VALUES(reason), created_at = NOW()`,
+ [teamId, memberKey, effect, actorUserId, actorUsername, reason],
+ )
+}
+
+async function clearOverride(teamId, memberKey) {
+ const res = await query('DELETE FROM team_leader_overrides WHERE team_id = ? AND member_key = ?',
+ [teamId, memberKey])
+ return res.affectedRows > 0
+}
+
+module.exports = {
+ activeGrant,
+ grantLedger,
+ activeGrants,
+ overridesForTeam,
+ overrideFor,
+ setOverride,
+ clearOverride,
+}
diff --git a/server/src/model/teams/teamAccess.model.js b/server/src/model/teams/teamAccess.model.js
new file mode 100644
index 0000000..83cecfe
--- /dev/null
+++ b/server/src/model/teams/teamAccess.model.js
@@ -0,0 +1,131 @@
+// ── The four authority paths ───────────────────────────────────────────────
+//
+// The single most important structural rule in TEAMS.md (§2.5): these are four
+// tables answering four questions, and **no resolver reads another path's table.**
+//
+// 1. Is this account a member? module team_members
+// 2. Does this account lead the Team? module team_members.is_leader,
+// plus a staff override
+// 3. May it use the Team forum? CORE team_forum_grants OR path 1
+// 4. May it get external-platform CORE, nothing of its own
+// access? derived
+//
+// The temptation this file exists to resist is collapsing 1 and 3 into one
+// boolean. They answer different questions about different populations: a forum
+// grant may name any Runic Gateway account, including one with no game identity
+// at all — that is the point of it, since letting an unlinked guildmate into the
+// forum must not require a staff ticket. Treating "has forum access" as "is a
+// member" would put that person on the roster, in the member count, and into the
+// external-platform grant, which is where it stops being a modelling preference
+// and becomes an impersonation risk (path 4 below).
+//
+// Non-contamination is the invariant: a manual grant never writes the membership
+// projection, in either direction, ever. Both facts coexist and neither migrates
+// into the other.
+
+const accessDb = require('./teamAccess.db')
+const teamsDb = require('./teams.db')
+const identities = require('../userIdentities/userIdentities.model')
+
+/**
+ * Path 3 — forum access. Two reads, OR'd, and nothing else.
+ *
+ * `viaGrant` is reported even when membership also holds, deliberately: both
+ * facts are true, the UI presents membership as the current reason, and the grant
+ * survives as audit history. Collapsing them into one boolean is what loses the
+ * record of who let this person in and why.
+ */
+async function forumAccess(teamId, userId) {
+ if (!userId) return { allowed: false, viaMembership: false, viaGrant: false, isLeader: false }
+
+ const [grant, member] = await Promise.all([
+ accessDb.activeGrant(teamId, userId), // path 3's own table
+ teamsDb.activeByUser(teamId, userId), // path 1
+ ])
+
+ return {
+ allowed: Boolean(grant) || Boolean(member),
+ viaMembership: Boolean(member),
+ viaGrant: Boolean(grant),
+ isLeader: member ? await isLeader(teamId, member) : false,
+ }
+}
+
+/**
+ * Path 2 — leadership, with the staff override applied ON TOP of the synced value
+ * at read time (§2.5.1).
+ *
+ * Applied at read rather than written into the projection because the sync owns
+ * that column and rewrites it every interval. An override that lived in
+ * `team_members` would be undone fifteen minutes after staff set it, which is the
+ * whole reason this is a separate table read here.
+ */
+async function isLeader(teamId, member) {
+ if (!member) return false
+ const override = await accessDb.overrideFor(teamId, member.member_key)
+ if (override) return override.effect === 'grant'
+ return Boolean(member.is_leader)
+}
+
+/** Leadership for a caller identified by user id rather than by a member row. */
+async function isLeaderByUser(teamId, userId) {
+ if (!userId) return false
+ const member = await teamsDb.activeByUser(teamId, userId)
+ return isLeader(teamId, member)
+}
+
+/**
+ * Path 4 — external-platform eligibility. Computed, no table of its own, and
+ * deliberately blind to path 3.
+ *
+ * The reason, stated so nobody "fixes" it later: an integration cannot verify
+ * that an unlinked, forum-granted account corresponds to a real game member, so
+ * it must not hand that account a privilege on a platform where impersonation has
+ * consequences. A forum is a room on the operator's own site with a known
+ * moderator; a Discord role is an identity claim in someone else's space.
+ */
+async function externalEligible(teamId, userId, platform) {
+ if (!userId || !platform) return false
+ const member = await teamsDb.activeByUser(teamId, userId) // path 1 ONLY
+ if (!member || member.user_id == null) return false // must be a LINKED game member
+ const linked = await identities.listForUser(userId)
+ return linked.some((i) => i.provider === platform)
+}
+
+/**
+ * A team's roster with overrides folded in, for the admin view and the Team page.
+ *
+ * The rows returned carry `is_leader` as RESOLVED — synced value plus override —
+ * and `is_leader_synced` as what the game actually said, so the admin surface can
+ * show that a decision was made rather than silently presenting it as fact.
+ */
+async function rosterWithOverrides(teamId, { includeDeparted = false } = {}) {
+ const [members, overrides] = await Promise.all([
+ teamsDb.membersByTeam(teamId, { includeDeparted }),
+ accessDb.overridesForTeam(teamId),
+ ])
+ const byKey = new Map(overrides.map((o) => [o.member_key, o]))
+ return members.map((m) => {
+ const override = byKey.get(m.member_key)
+ return {
+ ...m,
+ is_leader_synced: Boolean(m.is_leader),
+ is_leader: override ? override.effect === 'grant' : Boolean(m.is_leader),
+ leader_override: override
+ ? { effect: override.effect, reason: override.reason, by: override.actor_username, at: override.created_at }
+ : null,
+ }
+ })
+}
+
+module.exports = {
+ forumAccess,
+ isLeader,
+ isLeaderByUser,
+ externalEligible,
+ rosterWithOverrides,
+ setLeaderOverride: accessDb.setOverride,
+ clearLeaderOverride: accessDb.clearOverride,
+ grantLedger: accessDb.grantLedger,
+ activeGrants: accessDb.activeGrants,
+}
diff --git a/server/test/teamAccess.test.js b/server/test/teamAccess.test.js
new file mode 100644
index 0000000..3d70ce2
--- /dev/null
+++ b/server/test/teamAccess.test.js
@@ -0,0 +1,209 @@
+// The four authority paths, and the rule that they stay four
+// (docs/website/TEAMS.md §2.5).
+//
+// Two of these tests are named for invariants rather than for behaviour, because
+// what they protect is a structural property that a perfectly reasonable-looking
+// refactor destroys: "has forum access" is never read as "is a member", and a
+// grant never writes the membership projection. Both are one `||` away from being
+// wrong, and neither failure is visible on any screen — the first shows up as a
+// stranger on a public roster, the second as a Discord role handed to an account
+// nobody can tie to a real player.
+const { test, beforeEach, afterEach } = require('node:test')
+const assert = require('node:assert/strict')
+
+const accessDb = require('../src/model/teams/teamAccess.db')
+const teamsDb = require('../src/model/teams/teams.db')
+const identities = require('../src/model/userIdentities/userIdentities.model')
+const access = require('../src/model/teams/teamAccess.model')
+
+const saved = []
+function patch(mod, name, fn) {
+ saved.push([mod, name, mod[name]])
+ mod[name] = fn
+}
+
+// Every read the four paths can make, stubbed to "nothing there". Each test then
+// states only the fact it is about, which is what makes a crossed path obvious:
+// a resolver reading a table it should not would come back empty and the
+// assertion would say so.
+function stubAll() {
+ patch(accessDb, 'activeGrant', async () => undefined)
+ patch(accessDb, 'overrideFor', async () => undefined)
+ patch(accessDb, 'overridesForTeam', async () => [])
+ patch(teamsDb, 'activeByUser', async () => undefined)
+ patch(teamsDb, 'membersByTeam', async () => [])
+ patch(identities, 'listForUser', async () => [])
+}
+
+const memberRow = (extra = {}) => ({
+ team_id: 1, member_key: '0x1', user_id: 7, is_leader: 0, status: 'active', display_name: 'Aldric', ...extra,
+})
+const grantRow = (extra = {}) => ({ id: 1, team_id: 1, user_id: 7, granted_by: 2, revoked_at: null, ...extra })
+
+beforeEach(stubAll)
+afterEach(() => {
+ while (saved.length) {
+ const [mod, name, fn] = saved.pop()
+ mod[name] = fn
+ }
+})
+
+// ── Path 3: forum access is membership OR a grant ──────────────────────────
+
+test('a member has forum access via membership', async () => {
+ patch(teamsDb, 'activeByUser', async () => memberRow())
+ const result = await access.forumAccess(1, 7)
+ assert.deepEqual(result, { allowed: true, viaMembership: true, viaGrant: false, isLeader: false })
+})
+
+test('a granted non-member has forum access via the grant', async () => {
+ patch(accessDb, 'activeGrant', async () => grantRow())
+ const result = await access.forumAccess(1, 7)
+ assert.deepEqual(result, { allowed: true, viaMembership: false, viaGrant: true, isLeader: false })
+})
+
+test('both reasons are reported when both hold', async () => {
+ // Not collapsed into one boolean: both facts are true, membership is what the
+ // UI shows as the current reason, and the grant stays as the record of who let
+ // this person in before they were a member.
+ patch(accessDb, 'activeGrant', async () => grantRow())
+ patch(teamsDb, 'activeByUser', async () => memberRow())
+ const result = await access.forumAccess(1, 7)
+ assert.equal(result.viaMembership, true)
+ assert.equal(result.viaGrant, true)
+})
+
+test('a revoked grant and no membership is no access', async () => {
+ // activeGrant returns nothing for a revoked row — the resolver never sees one.
+ const result = await access.forumAccess(1, 7)
+ assert.equal(result.allowed, false)
+})
+
+test('an anonymous caller is refused without touching a table', async () => {
+ let reads = 0
+ patch(accessDb, 'activeGrant', async () => { reads += 1 })
+ patch(teamsDb, 'activeByUser', async () => { reads += 1 })
+ const result = await access.forumAccess(1, null)
+ assert.equal(result.allowed, false)
+ assert.equal(reads, 0)
+})
+
+// ── Invariant 3: non-contamination ─────────────────────────────────────────
+
+test('INVARIANT — a grant never writes the membership projection', async () => {
+ // The grant path reads its own table and nothing else. Asserted by making every
+ // membership WRITE explode: if resolving a grant ever wrote a member row, this
+ // is where it would surface.
+ for (const name of ['upsertMember', 'markDeparted', 'setLeaders', 'setMemberLeader']) {
+ patch(teamsDb, name, async () => { throw new Error(`forumAccess wrote team_members via ${name}`) })
+ }
+ patch(accessDb, 'activeGrant', async () => grantRow())
+ const result = await access.forumAccess(1, 7)
+ assert.equal(result.allowed, true)
+ assert.equal(result.viaMembership, false, 'a grant is not a membership, in either direction')
+})
+
+test('INVARIANT — a granted, unlinked user is not on the roster', async () => {
+ // The roster is path 1's table alone. A granted user with no membership row
+ // appears nowhere in it, which is what keeps them out of every membership count.
+ patch(accessDb, 'activeGrant', async () => grantRow({ user_id: 99 }))
+ patch(teamsDb, 'membersByTeam', async () => [memberRow()])
+
+ const roster = await access.rosterWithOverrides(1)
+ assert.equal(roster.length, 1)
+ assert.equal(roster.every((m) => m.user_id !== 99), true, 'a forum guest is not a member')
+})
+
+// ── Path 4: external access is blind to path 3 ─────────────────────────────
+
+test('INVARIANT — a forum grant does not make an account externally eligible', async () => {
+ // The named test from §2.5. An integration cannot verify that an unlinked,
+ // forum-granted account corresponds to a real game member, so it must not hand
+ // that account a privilege on a platform where impersonation has consequences.
+ patch(accessDb, 'activeGrant', async () => grantRow())
+ patch(identities, 'listForUser', async () => [{ provider: 'discord', subject: 'd1' }])
+
+ assert.equal(await access.externalEligible(1, 7, 'discord'), false)
+})
+
+test('a linked member with a linked Discord identity is eligible', async () => {
+ patch(teamsDb, 'activeByUser', async () => memberRow({ user_id: 7 }))
+ patch(identities, 'listForUser', async () => [{ provider: 'discord', subject: 'd1' }])
+ assert.equal(await access.externalEligible(1, 7, 'discord'), true)
+})
+
+test('a member with no Discord identity is not eligible — hop 3 of the chain', async () => {
+ patch(teamsDb, 'activeByUser', async () => memberRow())
+ patch(identities, 'listForUser', async () => [{ provider: 'google', subject: 'g1' }])
+ assert.equal(await access.externalEligible(1, 7, 'discord'), false)
+})
+
+test('eligibility is per platform, not "linked to anything"', async () => {
+ patch(teamsDb, 'activeByUser', async () => memberRow())
+ patch(identities, 'listForUser', async () => [{ provider: 'discord', subject: 'd1' }])
+ assert.equal(await access.externalEligible(1, 7, 'matrix'), false)
+})
+
+test('a non-member is never eligible', async () => {
+ patch(identities, 'listForUser', async () => [{ provider: 'discord', subject: 'd1' }])
+ assert.equal(await access.externalEligible(1, 7, 'discord'), false)
+})
+
+// ── Path 2: leadership, and the staff override on top ──────────────────────
+
+test('leadership follows the synced value when no override exists', async () => {
+ patch(teamsDb, 'activeByUser', async () => memberRow({ is_leader: 1 }))
+ assert.equal(await access.isLeaderByUser(1, 7), true)
+})
+
+test('a deny override outranks a synced leader', async () => {
+ patch(teamsDb, 'activeByUser', async () => memberRow({ is_leader: 1 }))
+ patch(accessDb, 'overrideFor', async () => ({ member_key: '0x1', effect: 'deny' }))
+ assert.equal(await access.isLeaderByUser(1, 7), false)
+})
+
+test('a grant override promotes someone the game does not call a leader', async () => {
+ patch(teamsDb, 'activeByUser', async () => memberRow({ is_leader: 0 }))
+ patch(accessDb, 'overrideFor', async () => ({ member_key: '0x1', effect: 'grant' }))
+ assert.equal(await access.isLeaderByUser(1, 7), true)
+})
+
+test('an override survives a resync, because it is never written into the projection', async () => {
+ // The projection keeps saying what the game says; the override keeps saying what
+ // staff decided. Applied at READ time, so a sync fifteen minutes later cannot
+ // undo it — which is the entire point of §2.5.1.
+ patch(accessDb, 'overridesForTeam', async () => [
+ { member_key: '0x1', effect: 'deny', reason: 'harassment', actor_username: 'mod1', created_at: 'then' },
+ ])
+ patch(teamsDb, 'membersByTeam', async () => [memberRow({ is_leader: 1 })])
+
+ const roster = await access.rosterWithOverrides(1)
+ assert.equal(roster[0].is_leader, false, 'the resolved answer is the override')
+ assert.equal(roster[0].is_leader_synced, true, 'what the game says is still visible')
+ assert.equal(roster[0].leader_override.reason, 'harassment')
+ assert.equal(roster[0].leader_override.by, 'mod1')
+})
+
+test('a member with no override carries no override field', async () => {
+ patch(teamsDb, 'membersByTeam', async () => [memberRow({ is_leader: 1 })])
+ const roster = await access.rosterWithOverrides(1)
+ assert.equal(roster[0].leader_override, null)
+ assert.equal(roster[0].is_leader, true)
+})
+
+test('leadership resolves through forumAccess too, override included', async () => {
+ patch(teamsDb, 'activeByUser', async () => memberRow({ is_leader: 0 }))
+ patch(accessDb, 'overrideFor', async () => ({ member_key: '0x1', effect: 'grant' }))
+ const result = await access.forumAccess(1, 7)
+ assert.equal(result.isLeader, true)
+})
+
+test('a granted non-member is never a leader', async () => {
+ // isLeader is path 2, which is a property of a MEMBER row. Someone with only a
+ // forum grant has no member row, so there is nothing to promote.
+ patch(accessDb, 'activeGrant', async () => grantRow())
+ patch(accessDb, 'overrideFor', async () => ({ member_key: '0x1', effect: 'grant' }))
+ const result = await access.forumAccess(1, 7)
+ assert.equal(result.allowed, true)
+ assert.equal(result.isLeader, false)
+})
--
2.49.1
From 8fe2e014664d32380f0d9d41bd2c578ad5618ed0 Mon Sep 17 00:00:00 2001
From: wtclaude
Date: Mon, 17 Aug 2026 15:08:58 -0500
Subject: [PATCH 05/35] feat(teams): reserved-name screening, auto-hide, and
the admin-approval gate
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The one place untrusted game data becomes a public page (docs/website/TEAMS.md
§2.8), and the gate on releasing it (§2.9).
A Team's name is written by a player, in the game, with no review, and this
platform turns it into a public page, a URL and eventually a Discord channel
name. Someone naming their guild "Admin" or " Staff" gets an
official-looking page on the operator's own site for free.
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 files it in a review queue, and it keeps working
completely for its own members: their forum, their grants, their notifications.
The people in it are not being punished for a name their leader chose.
That asymmetry -- a false positive costs a human glance, a false negative costs
an impersonated staff page -- is what lets the matcher be conservative. It is not
licence to be sloppy the other way: a check that fires on "Badminton" gets
switched off, and then the real cost is paid in full. So matching is whole WORDS
after normalisation, never substrings, following the precedent
scripts/checkModuleIdentifiers.js set for exactly this reason.
Three matcher gaps found by writing the tests, all real impersonation vectors:
- "Guild of Moderators" did not match `moderator`. Only a trailing s off the
WHOLE term is stripped, so "Nomads" still does not match `mod`.
- "G.M." normalises to two single-letter words and matched nothing. A run of
two or more single-letter words is now also offered joined. Deliberately not
a whole-name condensation, which would re-admit substring matching.
- The multi-word condensed form was already handled and is what makes
"RunicGateway" match the two-word term -- the form an impersonator would
reach for, since it is what the Gitea org and every URL use.
Terms resolve at CHECK time, never baked in, so renaming a deployment protects
the new name without a redeploy. A failed settings read falls back to the static
role and project terms rather than to an empty list: screening fewer terms is
bad, screening none is the whole hole.
Re-screening runs on every reconcile, over names no human has ruled on. Names are
immutable per row, so it only ever changes an outcome when the TERM LIST changed
-- an operator adding one, or a rename -- which is exactly what a create-time-only
check would miss forever. `name_reviewed_at` is what makes a staff decision
sticky; without it an override would be undone every fifteen minutes.
The gate is scoped to three actions because they publish untrusted game-sourced
strings, and to nothing else. Ordinary forum grants, leadership overrides,
archives and forum moderation still apply immediately and are audited. A
moderator initiating one files a pending request; an admin applies at once.
Never four-eyes on admins: users.role defaults to admin and `npm run seed`
creates exactly one, so most deployments have precisely one and a second-approver
rule would wedge them with no way out.
Hiding is 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.
Two concurrency details worth the review: a decision moves the row out of
`pending` under a guard and applies its effect only if the row actually moved,
so two admins clicking approve cannot double-apply or overwrite each other's
record; and a JSON payload is parsed defensively, because the driver returns
JSON columns already parsed on some versions and as a string on others.
Screening is stubbed in the reconciler's own tests -- it is a separate unit, and
the real call reads settings, which this suite must never do against a live
database. That was caught the hard way: the suite went from 11s to hanging, and
the cause was the reconciler reaching a dead pool through the new call.
44 tests in the reconciler file (up from 39), 19 for the matcher, 25 for the
gate. Full suite 877 passed, 0 failed.
Refs docs/website/TEAMS.md §2.8, §2.9, Part 12 phase 2
Co-Authored-By: Claude
---
server/src/model/teams/teamModeration.db.js | 123 ++++++++
.../src/model/teams/teamModeration.model.js | 239 ++++++++++++++
server/src/model/teams/teamSync.model.js | 31 +-
server/src/utils/reservedNames.js | 212 +++++++++++++
server/test/reservedNames.test.js | 192 ++++++++++++
server/test/teamModeration.test.js | 293 ++++++++++++++++++
server/test/teamSync.test.js | 65 ++++
7 files changed, 1147 insertions(+), 8 deletions(-)
create mode 100644 server/src/model/teams/teamModeration.db.js
create mode 100644 server/src/model/teams/teamModeration.model.js
create mode 100644 server/src/utils/reservedNames.js
create mode 100644 server/test/reservedNames.test.js
create mode 100644 server/test/teamModeration.test.js
diff --git a/server/src/model/teams/teamModeration.db.js b/server/src/model/teams/teamModeration.db.js
new file mode 100644
index 0000000..f69174f
--- /dev/null
+++ b/server/src/model/teams/teamModeration.db.js
@@ -0,0 +1,123 @@
+// SQL for the reserved-name review queue and the §2.9 approval queue.
+
+const { query } = require('../../utils/db')
+
+// ── The hide/display state on `teams` ──────────────────────────────────────
+
+async function setHidden(teamId, { hidden, reason, term }) {
+ await query(
+ 'UPDATE teams SET hidden = ?, hidden_reason = ?, hidden_term = ? WHERE id = ?',
+ [hidden ? 1 : 0, hidden ? reason : null, hidden ? term || null : null, teamId],
+ )
+}
+
+/**
+ * Record that a human has decided about this name.
+ *
+ * What makes a staff decision STICKY (§2.8.3). Re-screening runs on every sync,
+ * and without this stamp an operator adding a reserved term — or simply renaming
+ * the deployment — would re-hide a Team staff had already allowed, every fifteen
+ * minutes, forever.
+ */
+async function markNameReviewed(teamId) {
+ await query('UPDATE teams SET name_reviewed_at = NOW() WHERE id = ?', [teamId])
+}
+
+async function setDisplayNameOverride(teamId, displayName) {
+ await query('UPDATE teams SET display_name_override = ? WHERE id = ?', [displayName, teamId])
+}
+
+/** Active teams whose name has never been screened by a human. */
+async function unreviewedActive(moduleId) {
+ return query(
+ `SELECT id, name, hidden, hidden_reason FROM teams
+ WHERE module_id = ? AND status = 'active' AND name_reviewed_at IS NULL`,
+ [moduleId],
+ )
+}
+
+/** The reserved-name review queue (§2.8.3). */
+async function reviewQueue() {
+ return query(
+ `SELECT id, name, slug, hidden_term, display_name_override, member_count, created_at
+ FROM teams
+ WHERE status = 'active' AND hidden = 1 AND hidden_reason = 'reserved_name' AND name_reviewed_at IS NULL
+ ORDER BY created_at DESC`,
+ )
+}
+
+// ── team_moderation_requests (§2.9) ────────────────────────────────────────
+
+const REQUEST_COLUMNS = `
+ id, team_id, action, payload, reason, requested_by, requested_username, requested_at,
+ status, decided_by, decided_username, decided_at, decision_note`
+
+async function insertRequest({ teamId, action, payload, reason, requestedBy, requestedUsername }) {
+ const res = await query(
+ `INSERT INTO team_moderation_requests
+ (team_id, action, payload, reason, requested_by, requested_username)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ [teamId, action, payload == null ? null : JSON.stringify(payload), reason, requestedBy, requestedUsername],
+ )
+ return res.insertId
+}
+
+async function findRequest(id) {
+ const rows = await query(`SELECT ${REQUEST_COLUMNS} FROM team_moderation_requests WHERE id = ?`, [id])
+ return rows[0]
+}
+
+/** The approval queue. Decided rows are kept — see §2.9 — so `status` is a filter. */
+async function listRequests({ status = 'pending', limit = 100 } = {}) {
+ const params = []
+ let sql = `SELECT r.${REQUEST_COLUMNS.trim().split(/,\s*/).join(', r.')},
+ t.name AS team_name, t.slug AS team_slug
+ FROM team_moderation_requests r JOIN teams t ON t.id = r.team_id`
+ if (status !== 'all') {
+ sql += ' WHERE r.status = ?'
+ params.push(status)
+ }
+ sql += ' ORDER BY r.requested_at DESC, r.id DESC LIMIT ?'
+ params.push(limit)
+ return query(sql, params)
+}
+
+/**
+ * Decide a request, but only if it is still pending.
+ *
+ * The `status = 'pending'` guard is the concurrency control: two admins opening
+ * the same queue and both clicking approve would otherwise each apply the action,
+ * and the second would overwrite the first's record of who decided it. The caller
+ * applies the effect only when this reports a row was actually moved.
+ */
+async function decideRequest(id, { status, decidedBy, decidedUsername, note }) {
+ const res = await query(
+ `UPDATE team_moderation_requests
+ SET status = ?, decided_by = ?, decided_username = ?, decided_at = NOW(), decision_note = ?
+ WHERE id = ? AND status = 'pending'`,
+ [status, decidedBy, decidedUsername, note, id],
+ )
+ return res.affectedRows > 0
+}
+
+/** Pending requests for one team — shown on its admin page so a second is not filed. */
+async function pendingForTeam(teamId) {
+ return query(
+ `SELECT ${REQUEST_COLUMNS} FROM team_moderation_requests
+ WHERE team_id = ? AND status = 'pending' ORDER BY requested_at`,
+ [teamId],
+ )
+}
+
+module.exports = {
+ setHidden,
+ markNameReviewed,
+ setDisplayNameOverride,
+ unreviewedActive,
+ reviewQueue,
+ insertRequest,
+ findRequest,
+ listRequests,
+ decideRequest,
+ pendingForTeam,
+}
diff --git a/server/src/model/teams/teamModeration.model.js b/server/src/model/teams/teamModeration.model.js
new file mode 100644
index 0000000..991073b
--- /dev/null
+++ b/server/src/model/teams/teamModeration.model.js
@@ -0,0 +1,239 @@
+// ── Impersonation controls, and the approval gate on them ──────────────────
+//
+// TEAMS.md §2.8–§2.9. Two things live here:
+//
+// 1. **Auto-hide**, which turns a reserved-name match into a suppressed Team
+// and a review queue entry rather than into a refusal. Core cannot refuse a
+// name — the guild exists in the game and core is a mirror of it.
+//
+// 2. **The approval gate**, which is scoped to the three actions that RELEASE
+// untrusted game-sourced strings onto public surfaces, and to nothing else.
+//
+// **The gate's scope is the part most likely to be misread.** It is not a general
+// staff-approval workflow. Ordinary forum grants, leadership overrides, archives
+// and forum moderation all still apply immediately and are audited, exactly as
+// before. Three actions are gated, and the question that admits a fourth is
+// always the same one: *does this publish untrusted game data?*
+//
+// - clearing a reserved_name hide — publishes a name that tripped the list
+// - setting a display_name_override — substitutes free text into the same
+// public surfaces
+// - un-hiding a staff-hidden Team — reverses a deliberate suppression
+//
+// **Moderator-initiated, admin-approved — never four-eyes on admins.** `users.role`
+// defaults to admin and `npm run seed` creates exactly one, so most deployments
+// have precisely one admin. A rule requiring a second would wedge them with no
+// way out, which is a worse failure than the one it guards against.
+
+const moderationDb = require('./teamModeration.db')
+const teamsDb = require('./teams.db')
+const reservedNames = require('../../utils/reservedNames')
+const activity = require('../activity/activity.model')
+const log = require('../../utils/logger')('teams')
+
+const GATED_ACTIONS = ['unhide', 'display_name_override', 'clear_display_name_override']
+
+const isAdmin = (actor) => Boolean(actor) && actor.role === 'admin'
+
+/**
+ * Screen a name and return the columns a create should carry.
+ *
+ * Never throws: screening reads settings, and a database hiccup during a
+ * reconcile must not stop a Team being created. It fails OPEN on the create — the
+ * Team appears — because the re-screen on the next sync will catch it, and a
+ * reconcile that aborts halfway is worse than a name that is public for one
+ * interval. That is a deliberate trade and it is the reason re-screening exists
+ * at all rather than being a create-time-only check.
+ */
+async function screenForCreate(name) {
+ try {
+ const { reserved, term } = await reservedNames.screen(name)
+ if (!reserved) return { hidden: false }
+ log.warn('team auto-hidden: its name matched a reserved term', { name, term })
+ return { hidden: true, hiddenReason: 'reserved_name', hiddenTerm: term }
+ } catch (err) {
+ log.error('reserved-name screening failed; the team is created unscreened', {
+ name, message: err.message,
+ })
+ return { hidden: false }
+ }
+}
+
+/**
+ * Re-screen every active Team whose name no human has ruled on.
+ *
+ * Names are immutable per row, so this only ever changes an outcome when the TERM
+ * LIST changed — an operator adding a term, or the deployment being renamed. That
+ * is precisely the case a create-time-only check would miss forever.
+ *
+ * A Team staff have already decided about is skipped, and that stickiness is the
+ * point: without it, an override would be undone on the next sweep.
+ */
+async function rescreen(moduleId) {
+ let hidden = 0
+ try {
+ const rows = await moderationDb.unreviewedActive(moduleId)
+ for (const row of rows) {
+ if (row.hidden) continue
+ // eslint-disable-next-line no-await-in-loop
+ const { reserved, term } = await reservedNames.screen(row.name)
+ if (!reserved) continue
+ // eslint-disable-next-line no-await-in-loop
+ await moderationDb.setHidden(row.id, { hidden: true, reason: 'reserved_name', term })
+ hidden += 1
+ log.warn('team hidden by a re-screen: the reserved terms changed', { id: row.id, name: row.name, term })
+ }
+ } catch (err) {
+ log.error('re-screening failed', { message: err.message })
+ }
+ return hidden
+}
+
+// ── The three gated actions ────────────────────────────────────────────────
+
+/**
+ * Apply a gated action, or file it for approval.
+ *
+ * The role check is answered LIVE against the database on every request by core's
+ * admin middleware, so "is this caller an admin" is not read from a token claim
+ * that a demotion would not have invalidated.
+ */
+async function requestOrApply({ req, actor, teamId, action, payload, reason }) {
+ if (!GATED_ACTIONS.includes(action)) throw new Error(`not a gated action: "${action}"`)
+ const team = await teamsDb.findById(teamId)
+ if (!team) return { ok: false, status: 404, error: 'team not found' }
+
+ if (!isAdmin(actor)) {
+ const id = await moderationDb.insertRequest({
+ teamId, action, payload, reason, requestedBy: actor.id, requestedUsername: actor.username,
+ })
+ await activity.log({
+ req,
+ action: 'team.moderation.request',
+ detail: `${actor.username} (#${actor.id}) requested "${action}" on team "${team.name}" (#${teamId})`
+ + `${reason ? `: "${reason}"` : ''}`,
+ })
+ return { ok: true, pending: true, requestId: id }
+ }
+
+ await applyAction({ req, actor, team, action, payload, reason })
+ return { ok: true, pending: false }
+}
+
+/** The effect itself. Reached by an admin directly, or by an approval. */
+async function applyAction({ req, actor, team, action, payload, reason }) {
+ switch (action) {
+ case 'unhide':
+ await moderationDb.setHidden(team.id, { hidden: false })
+ // A human has now ruled on this name, so no later sweep re-hides it.
+ await moderationDb.markNameReviewed(team.id)
+ break
+ case 'display_name_override':
+ await moderationDb.setDisplayNameOverride(team.id, payload.displayName)
+ await moderationDb.markNameReviewed(team.id)
+ break
+ case 'clear_display_name_override':
+ await moderationDb.setDisplayNameOverride(team.id, null)
+ break
+ default:
+ throw new Error(`not a gated action: "${action}"`)
+ }
+
+ await activity.log({
+ req,
+ action: `team.${action}`,
+ detail: `${actor.username} (#${actor.id}) applied "${action}" to team "${team.name}" (#${team.id})`
+ + `${payload && payload.displayName ? ` as "${payload.displayName}"` : ''}`
+ + `${reason ? `: "${reason}"` : ''}`,
+ })
+}
+
+/**
+ * Hide a Team. NOT gated — suppression is always safe (§2.11).
+ *
+ * The asymmetry is the whole design: publishing untrusted data needs a second
+ * pair of eyes, and withdrawing it needs to be possible at once, by whoever is
+ * on duty.
+ */
+async function hide({ req, actor, teamId, reason }) {
+ const team = await teamsDb.findById(teamId)
+ if (!team) return { ok: false, status: 404, error: 'team not found' }
+ await moderationDb.setHidden(teamId, { hidden: true, reason: 'staff' })
+ await activity.log({
+ req,
+ action: 'team.hide',
+ detail: `${actor.username} (#${actor.id}) hid team "${team.name}" (#${teamId})`
+ + `${reason ? `: "${reason}"` : ''}`,
+ })
+ return { ok: true }
+}
+
+/**
+ * Decide a pending request. Admin only.
+ *
+ * The effect is applied only when the row actually moved out of `pending`, so two
+ * admins deciding the same request race safely: the second is told it was already
+ * decided rather than applying the action a second time.
+ */
+async function decide({ req, actor, requestId, status, note }) {
+ if (!isAdmin(actor)) return { ok: false, status: 403, error: 'only an admin may decide a request' }
+ if (!['approved', 'rejected'].includes(status)) {
+ return { ok: false, status: 400, error: 'status must be approved or rejected' }
+ }
+
+ const request = await moderationDb.findRequest(requestId)
+ if (!request) return { ok: false, status: 404, error: 'request not found' }
+ if (request.status !== 'pending') {
+ return { ok: false, status: 409, error: `request is already ${request.status}` }
+ }
+
+ const moved = await moderationDb.decideRequest(requestId, {
+ status, decidedBy: actor.id, decidedUsername: actor.username, note,
+ })
+ if (!moved) return { ok: false, status: 409, error: 'request was decided by someone else' }
+
+ const team = await teamsDb.findById(request.team_id)
+ if (status === 'approved' && team) {
+ await applyAction({
+ req,
+ actor,
+ team,
+ action: request.action,
+ payload: parsePayload(request.payload),
+ reason: request.reason,
+ })
+ }
+
+ await activity.log({
+ req,
+ action: `team.moderation.${status}`,
+ detail: `${actor.username} (#${actor.id}) ${status} request #${requestId} `
+ + `("${request.action}" on team #${request.team_id}, asked by ${request.requested_username || 'a deleted user'})`
+ + `${note ? `: "${note}"` : ''}`,
+ })
+ return { ok: true, applied: status === 'approved' }
+}
+
+// The driver returns JSON columns already parsed on some versions and as a string
+// on others, so this normalises rather than assuming either.
+function parsePayload(payload) {
+ if (payload == null) return {}
+ if (typeof payload === 'object') return payload
+ try {
+ return JSON.parse(payload)
+ } catch {
+ return {}
+ }
+}
+
+module.exports = {
+ screenForCreate,
+ rescreen,
+ requestOrApply,
+ hide,
+ decide,
+ reviewQueue: moderationDb.reviewQueue,
+ listRequests: moderationDb.listRequests,
+ pendingForTeam: moderationDb.pendingForTeam,
+ GATED_ACTIONS,
+}
diff --git a/server/src/model/teams/teamSync.model.js b/server/src/model/teams/teamSync.model.js
index 4d5f764..1847470 100644
--- a/server/src/model/teams/teamSync.model.js
+++ b/server/src/model/teams/teamSync.model.js
@@ -31,6 +31,7 @@
const teamsDb = require('./teams.db')
const teamProvider = require('./teamProvider')
+const moderation = require('./teamModeration.model')
const { slugify, uniqueSlug } = require('./teamSlug')
const settings = require('../settings/settings.model')
const log = require('../../utils/logger')('teams')
@@ -95,17 +96,23 @@ function backoffSeconds(consecutiveFailures, intervalS) {
// ── Applying one Team ──────────────────────────────────────────────────────
/**
- * Create the row for a Team core has not seen, deriving its slug.
+ * Create the row for a Team core has not seen, deriving its slug and screening
+ * its name against the reserved list (§2.8).
*
- * Screening the name against the reserved list happens here in a later commit;
- * the row is created either way, because core cannot refuse a name — the guild
- * already exists in the game and core is a mirror of it, not an authority over it.
+ * 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 id = await teamsDb.insertTeam({ moduleId, slug, ...team })
- log.info('team created', { moduleId, externalId: team.externalId, name: team.name, slug })
+ 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
}
@@ -291,9 +298,17 @@ async function runOnce(reason) {
}
}
+ // 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, total: answer.teams.length })
- return { ok: true, created, renamed, archived, rosters }
+ 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 ────────────────────────────────────────────────
diff --git a/server/src/utils/reservedNames.js b/server/src/utils/reservedNames.js
new file mode 100644
index 0000000..0d4c5b7
--- /dev/null
+++ b/server/src/utils/reservedNames.js
@@ -0,0 +1,212 @@
+// ── Reserved-name screening ────────────────────────────────────────────────
+//
+// The one place untrusted game data becomes a public page (TEAMS.md §2.8).
+//
+// A Team's name is written by a player, inside the game, with no review, and the
+// platform then turns it into a public page, a URL, a nav-reachable entity and
+// eventually a Discord channel name. Someone naming their guild "Admin",
+// "Moderator" or " 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 }
diff --git a/server/test/reservedNames.test.js b/server/test/reservedNames.test.js
new file mode 100644
index 0000000..91c4477
--- /dev/null
+++ b/server/test/reservedNames.test.js
@@ -0,0 +1,192 @@
+// Reserved-name screening (docs/website/TEAMS.md §2.8).
+//
+// Two failure modes with very different costs, and the tests are split along
+// that line:
+//
+// - a FALSE NEGATIVE puts an official-looking staff page on the operator's own
+// site, written by whoever typed a name into a guild stone;
+// - a FALSE POSITIVE hides a legitimate guild until a human glances at a queue.
+//
+// The second is cheap and recoverable, which is what lets the matcher be
+// conservative. It is not licence to be sloppy in the other direction: a check
+// that fires on "Badminton" is a check the operator switches off, and then the
+// first cost is paid in full.
+const { test, beforeEach, afterEach } = require('node:test')
+const assert = require('node:assert/strict')
+
+const settings = require('../src/model/settings/settings.model')
+const brand = require('../src/config/brand')
+const reserved = require('../src/utils/reservedNames')
+
+const saved = []
+function patch(mod, name, fn) {
+ saved.push([mod, name, mod[name]])
+ mod[name] = fn
+}
+
+beforeEach(() => {
+ // A deployment with a two-word brand and no operator additions, which is the
+ // shape that exercises the condensed-form rule.
+ patch(settings, 'getInstanceName', async () => 'UO Mysticmoon')
+ patch(settings, 'get', async () => null)
+})
+
+afterEach(() => {
+ while (saved.length) {
+ const [mod, name, fn] = saved.pop()
+ mod[name] = fn
+ }
+})
+
+const isReserved = async (name) => (await reserved.screen(name)).reserved
+const termFor = async (name) => (await reserved.screen(name)).term
+
+// ── The names this exists to catch ─────────────────────────────────────────
+
+test('bare role names are reserved', async () => {
+ for (const name of ['Admin', 'admin', 'ADMIN', 'Moderator', 'Staff', 'Owner', 'GM', 'Administrator']) {
+ assert.equal(await isReserved(name), true, `"${name}" must not become a public page`)
+ }
+})
+
+test('a role word inside a longer name is caught', async () => {
+ for (const name of ['The Admin Team', 'Server Staff', 'GM Council', 'Guild of Moderators']) {
+ assert.equal(await isReserved(name), true, `"${name}" is the impersonation this exists for`)
+ }
+})
+
+test('the deployment brand is reserved, in both presentations', async () => {
+ assert.equal(await isReserved('UO Mysticmoon'), true)
+ assert.equal(await isReserved('UOMysticmoon'), true, 'the condensed form is what an impersonator types')
+ assert.equal(await isReserved('uo-mysticmoon'), true)
+ assert.equal(await isReserved('UO_MYSTICMOON'), true)
+ assert.equal(await isReserved('UOMysticmoon Staff'), true)
+})
+
+test('the project name is reserved, in both of its legitimate presentations', async () => {
+ // "Runic Gateway" is correct; "RunicGateway" is what the Gitea org and every
+ // URL segment use, so it is the form someone would copy.
+ assert.equal(await isReserved('Runic Gateway'), true)
+ assert.equal(await isReserved('RunicGateway'), true)
+ assert.equal(await isReserved('runic-gateway'), true)
+ assert.equal(await isReserved('Runic_Gateway'), true)
+ assert.equal(await isReserved('RUNIC GATEWAY'), true)
+})
+
+test('operator additions are honoured', async () => {
+ patch(settings, 'get', async (key) => (key === reserved.OPERATOR_TERMS_KEY ? 'Council, Arbiter' : null))
+ assert.equal(await isReserved('The Council'), true)
+ assert.equal(await isReserved('Arbiter'), true)
+})
+
+test('repeated characters are squeezed', async () => {
+ assert.equal(await isReserved('Adminnn'), true)
+ assert.equal(await isReserved('Staaaff'), true)
+})
+
+test('punctuation between words does not evade the check', async () => {
+ assert.equal(await isReserved('[Admin]'), true)
+ assert.equal(await isReserved('~*~ Staff ~*~'), true)
+ assert.equal(await isReserved('G.M.'), true)
+})
+
+test('the matched term is reported, for the review queue', async () => {
+ assert.equal(await termFor('The Admin Team'), 'admin')
+ assert.equal(await termFor('UOMysticmoon'), 'UO Mysticmoon', 'shown in its stored form, not the input')
+})
+
+// ── The names it must NOT catch ────────────────────────────────────────────
+
+test('a word merely CONTAINING a reserved term is not reserved', async () => {
+ // The scar tissue this rule comes from: checkModuleIdentifiers.js tokenises
+ // precisely so `defaultImage` does not match "ultIma".
+ for (const name of ['Badminton', 'Badminton Club', 'Modest Proposal', 'Gmork', 'Playerless']) {
+ assert.equal(await isReserved(name), false, `"${name}" is a false positive that would discredit the check`)
+ }
+})
+
+test('ordinary guild names pass', async () => {
+ for (const name of [
+ 'The Silver Hand', 'Knights of the Round', 'Dread Pirates', 'Moonlight Traders',
+ 'The Guardians', 'Iron Wolves',
+ ]) {
+ assert.equal(await isReserved(name), false, `"${name}" is an ordinary guild`)
+ }
+})
+
+test('the condensed-form widening applies only to multi-word terms', async () => {
+ // Running the letters together is safe for a two-word term because it is
+ // specific; doing it for single-word terms is what would re-introduce
+ // substring matching through the back door.
+ assert.equal(await isReserved('Badminton'), false)
+ assert.equal(await isReserved('Grandmaster'), false, 'contains "gm" only as a substring')
+ assert.equal(await isReserved('Nomads'), false, 'contains "mod" only as a substring')
+})
+
+test('an empty or unusable name is not reserved', async () => {
+ for (const name of ['', ' ', null, undefined, '★☆★']) {
+ assert.equal(await isReserved(name), false)
+ }
+})
+
+// ── Resolution is at check time, and fails safe ────────────────────────────
+
+test('the brand is resolved at CHECK time, so a rename protects the new name', async () => {
+ patch(settings, 'getInstanceName', async () => 'Dragonspire')
+ assert.equal(await isReserved('Dragonspire'), true)
+
+ patch(settings, 'getInstanceName', async () => 'Emberfall')
+ assert.equal(await isReserved('Emberfall'), true, 'no redeploy should be needed to protect a new brand')
+})
+
+test('a failed settings read falls back to the static terms rather than to none', async () => {
+ // Screening fewer terms is bad; screening none is the entire hole.
+ patch(settings, 'getInstanceName', async () => { throw new Error('db down') })
+ patch(settings, 'get', async () => { throw new Error('db down') })
+
+ assert.equal(await isReserved('Admin'), true, 'the role list must survive a database outage')
+ assert.equal(await isReserved('Runic Gateway'), true)
+})
+
+test('BRAND_NAME is covered even when no site_title is set', async () => {
+ patch(settings, 'getInstanceName', async () => null)
+ assert.equal(await isReserved(brand.name), true)
+})
+
+test('the same term resolved twice is listed once', async () => {
+ // The brand and an operator term are frequently the same word, and reporting
+ // one match twice is noise in a queue a human reads.
+ patch(settings, 'getInstanceName', async () => 'Dragonspire')
+ patch(settings, 'get', async (key) => (key === reserved.OPERATOR_TERMS_KEY ? 'dragonspire' : null))
+ const terms = await reserved.reservedTerms()
+ const normalised = terms.map((t) => reserved.normalise(t))
+ assert.equal(new Set(normalised).size, normalised.length)
+})
+
+test('normalise folds case, diacritics and punctuation', () => {
+ assert.equal(reserved.normalise('Ünderdärk!'), 'underdark')
+ assert.equal(reserved.normalise(' The Silver Hand '), 'the silver hand')
+ assert.equal(reserved.normalise('Adminnn'), 'admin', 'repeats are squeezed on both sides')
+})
+
+test('plurals are caught, and near-misses are not', async () => {
+ // "Moderators" is the more natural guild name of the two, so missing it would
+ // miss the likelier case.
+ for (const name of ['Moderators', 'The Admins', 'Guild of Moderators', 'Owners']) {
+ assert.equal(await isReserved(name), true, `"${name}" impersonates as much as its singular`)
+ }
+ // Only a trailing s off the WHOLE term, so an ordinary word whose stem merely
+ // contains one does not fire.
+ for (const name of ['Nomads', 'Playerless', 'Gods']) {
+ assert.equal(await isReserved(name), false, `"${name}" is not a plural of a reserved term`)
+ }
+})
+
+test('an acronym spelled with punctuation is caught', async () => {
+ // "G.M." normalises to two single-letter words, neither of which is the term.
+ assert.equal(await isReserved('G.M.'), true)
+ assert.equal(await isReserved('G M Council'), true)
+ // …but joining single letters must not condense whole names, which would let a
+ // single-word term match inside an ordinary word again.
+ assert.equal(await isReserved('Badminton'), false)
+})
diff --git a/server/test/teamModeration.test.js b/server/test/teamModeration.test.js
new file mode 100644
index 0000000..77455b2
--- /dev/null
+++ b/server/test/teamModeration.test.js
@@ -0,0 +1,293 @@
+// Auto-hide and the §2.9 approval gate (docs/website/TEAMS.md §2.8–§2.9).
+//
+// The gate's SCOPE is what these tests pin down, and it is the thing most likely
+// to be widened by accident. Three actions are gated because they publish
+// untrusted game-sourced strings; everything else staff can do still applies at
+// once. Gating more would make this a general staff-approval workflow, which is a
+// different and much larger idea — and gating admins would wedge the
+// single-admin deployments `npm run seed` creates.
+const { test, beforeEach, afterEach } = require('node:test')
+const assert = require('node:assert/strict')
+
+const moderationDb = require('../src/model/teams/teamModeration.db')
+const teamsDb = require('../src/model/teams/teams.db')
+const activity = require('../src/model/activity/activity.model')
+const reservedNames = require('../src/utils/reservedNames')
+const moderation = require('../src/model/teams/teamModeration.model')
+
+const saved = []
+function patch(mod, name, fn) {
+ saved.push([mod, name, mod[name]])
+ mod[name] = fn
+}
+
+let db
+let logged
+
+const admin = { id: 1, username: 'root', role: 'admin' }
+const mod = { id: 2, username: 'mod1', role: 'moderator' }
+
+function stub() {
+ db = {
+ teams: new Map([[1, { id: 1, name: 'Admin', slug: 'admin', hidden: 1, hidden_reason: 'reserved_name' }]]),
+ requests: new Map(),
+ nextRequestId: 1,
+ }
+ logged = []
+
+ patch(teamsDb, 'findById', async (id) => db.teams.get(id))
+ patch(moderationDb, 'setHidden', async (id, { hidden, reason, term }) => {
+ const t = db.teams.get(id)
+ Object.assign(t, { hidden: hidden ? 1 : 0, hidden_reason: hidden ? reason : null, hidden_term: hidden ? term : null })
+ })
+ patch(moderationDb, 'markNameReviewed', async (id) => { db.teams.get(id).name_reviewed_at = 'now' })
+ patch(moderationDb, 'setDisplayNameOverride', async (id, value) => {
+ db.teams.get(id).display_name_override = value
+ })
+ patch(moderationDb, 'insertRequest', async (row) => {
+ const id = db.nextRequestId++
+ db.requests.set(id, { id, status: 'pending', ...row, payload: row.payload, team_id: row.teamId, requested_username: row.requestedUsername })
+ return id
+ })
+ patch(moderationDb, 'findRequest', async (id) => db.requests.get(id))
+ patch(moderationDb, 'decideRequest', async (id, { status, decidedUsername }) => {
+ const r = db.requests.get(id)
+ if (!r || r.status !== 'pending') return false
+ Object.assign(r, { status, decided_username: decidedUsername })
+ return true
+ })
+ patch(activity, 'log', async (entry) => { logged.push(entry) })
+}
+
+beforeEach(stub)
+afterEach(() => {
+ while (saved.length) {
+ const [m, name, fn] = saved.pop()
+ m[name] = fn
+ }
+})
+
+const actions = () => logged.map((l) => l.action)
+
+// ── Auto-hide at create ────────────────────────────────────────────────────
+
+test('a reserved name produces the hide columns a create should carry', async () => {
+ patch(reservedNames, 'screen', async () => ({ reserved: true, term: 'admin' }))
+ assert.deepEqual(await moderation.screenForCreate('Admin'), {
+ hidden: true, hiddenReason: 'reserved_name', hiddenTerm: 'admin',
+ })
+})
+
+test('an ordinary name carries nothing', async () => {
+ patch(reservedNames, 'screen', async () => ({ reserved: false, term: null }))
+ assert.deepEqual(await moderation.screenForCreate('The Silver Hand'), { hidden: false })
+})
+
+test('a screening failure creates the team unscreened rather than aborting the reconcile', async () => {
+ // A deliberate trade: the re-screen on the next sync catches it, and a
+ // reconcile that dies halfway through is worse than a name public for one
+ // interval. It is also why re-screening exists rather than being create-only.
+ patch(reservedNames, 'screen', async () => { throw new Error('settings unavailable') })
+ assert.deepEqual(await moderation.screenForCreate('Admin'), { hidden: false })
+})
+
+// ── Re-screening ───────────────────────────────────────────────────────────
+
+test('a re-screen hides a team whose name became reserved', async () => {
+ patch(moderationDb, 'unreviewedActive', async () => [{ id: 1, name: 'Council', hidden: 0 }])
+ patch(reservedNames, 'screen', async () => ({ reserved: true, term: 'Council' }))
+
+ assert.equal(await moderation.rescreen('uo'), 1)
+ assert.equal(db.teams.get(1).hidden, 1)
+ assert.equal(db.teams.get(1).hidden_term, 'Council')
+})
+
+test('a re-screen never re-hides a team staff have already ruled on', async () => {
+ // unreviewedActive excludes them by definition — the stamp is the mechanism,
+ // and without it an override would be undone on every sweep.
+ patch(moderationDb, 'unreviewedActive', async () => [])
+ patch(reservedNames, 'screen', async () => ({ reserved: true, term: 'admin' }))
+ assert.equal(await moderation.rescreen('uo'), 0)
+})
+
+test('a re-screen skips a team that is already hidden', async () => {
+ patch(moderationDb, 'unreviewedActive', async () => [{ id: 1, name: 'Admin', hidden: 1 }])
+ patch(reservedNames, 'screen', async () => { throw new Error('should not be screened again') })
+ assert.equal(await moderation.rescreen('uo'), 0)
+})
+
+test('a failing re-screen does not break the reconcile that called it', async () => {
+ patch(moderationDb, 'unreviewedActive', async () => { throw new Error('db down') })
+ assert.equal(await moderation.rescreen('uo'), 0)
+})
+
+// ── The gate: moderator asks, admin applies ────────────────────────────────
+
+test('a moderator un-hiding files a pending request and changes nothing public', async () => {
+ const result = await moderation.requestOrApply({
+ actor: mod, teamId: 1, action: 'unhide', reason: 'legitimate guild',
+ })
+ assert.equal(result.pending, true)
+ assert.equal(db.teams.get(1).hidden, 1, 'nothing is published until an admin agrees')
+ assert.equal(db.requests.get(1).status, 'pending')
+ assert.deepEqual(actions(), ['team.moderation.request'])
+})
+
+test('an admin un-hiding applies at once', async () => {
+ const result = await moderation.requestOrApply({ actor: admin, teamId: 1, action: 'unhide' })
+ assert.equal(result.pending, false)
+ assert.equal(db.teams.get(1).hidden, 0)
+ assert.equal(db.teams.get(1).name_reviewed_at, 'now', 'a human has now ruled on the name')
+ assert.deepEqual(actions(), ['team.unhide'])
+})
+
+test('an admin approving a moderator’s request publishes it', async () => {
+ await moderation.requestOrApply({ actor: mod, teamId: 1, action: 'unhide', reason: 'legit' })
+ assert.equal(db.teams.get(1).hidden, 1)
+
+ const result = await moderation.decide({ actor: admin, requestId: 1, status: 'approved' })
+ assert.equal(result.applied, true)
+ assert.equal(db.teams.get(1).hidden, 0)
+ assert.deepEqual(actions(), ['team.moderation.request', 'team.unhide', 'team.moderation.approved'])
+})
+
+test('a rejected request changes nothing but is kept', async () => {
+ await moderation.requestOrApply({ actor: mod, teamId: 1, action: 'unhide' })
+ const result = await moderation.decide({ actor: admin, requestId: 1, status: 'rejected', note: 'no' })
+
+ assert.equal(result.applied, false)
+ assert.equal(db.teams.get(1).hidden, 1)
+ assert.equal(db.requests.get(1).status, 'rejected', 'the record of a refusal is the part worth having')
+ assert.equal(actions().includes('team.unhide'), false)
+})
+
+test('a moderator may not decide a request', async () => {
+ await moderation.requestOrApply({ actor: mod, teamId: 1, action: 'unhide' })
+ const result = await moderation.decide({ actor: mod, requestId: 1, status: 'approved' })
+ assert.equal(result.ok, false)
+ assert.equal(result.status, 403)
+ assert.equal(db.teams.get(1).hidden, 1)
+})
+
+test('a request already decided cannot be decided again', async () => {
+ await moderation.requestOrApply({ actor: mod, teamId: 1, action: 'unhide' })
+ await moderation.decide({ actor: admin, requestId: 1, status: 'approved' })
+ const second = await moderation.decide({ actor: admin, requestId: 1, status: 'rejected' })
+ assert.equal(second.ok, false)
+ assert.equal(second.status, 409)
+ assert.equal(db.teams.get(1).hidden, 0, 'the first decision stands')
+})
+
+test('two admins deciding at once — only one applies', async () => {
+ // The row moves out of `pending` under a guard, and the effect follows only if
+ // it actually moved. Without that, both would apply the action and the second
+ // would overwrite the record of who decided it.
+ await moderation.requestOrApply({ actor: mod, teamId: 1, action: 'unhide' })
+ let applied = 0
+ patch(moderationDb, 'setHidden', async () => { applied += 1 })
+
+ const [a, b] = await Promise.all([
+ moderation.decide({ actor: admin, requestId: 1, status: 'approved' }),
+ moderation.decide({ actor: { ...admin, id: 3, username: 'root2' }, requestId: 1, status: 'approved' }),
+ ])
+ assert.equal([a.ok, b.ok].filter(Boolean).length, 1)
+ assert.equal(applied, 1)
+})
+
+test('an unknown request and an unknown team are refused, not guessed at', async () => {
+ assert.equal((await moderation.decide({ actor: admin, requestId: 99, status: 'approved' })).status, 404)
+ assert.equal((await moderation.requestOrApply({ actor: admin, teamId: 99, action: 'unhide' })).status, 404)
+})
+
+test('an invalid decision status is refused', async () => {
+ await moderation.requestOrApply({ actor: mod, teamId: 1, action: 'unhide' })
+ assert.equal((await moderation.decide({ actor: admin, requestId: 1, status: 'maybe' })).status, 400)
+})
+
+// ── The display-name override, through the same gate ───────────────────────
+
+test('a display name set by an admin applies; by a moderator it waits', async () => {
+ await moderation.requestOrApply({
+ actor: admin, teamId: 1, action: 'display_name_override', payload: { displayName: 'The Old Guard' },
+ })
+ assert.equal(db.teams.get(1).display_name_override, 'The Old Guard')
+
+ db.teams.get(1).display_name_override = null
+ await moderation.requestOrApply({
+ actor: mod, teamId: 1, action: 'display_name_override', payload: { displayName: 'Sneaky' },
+ })
+ assert.equal(db.teams.get(1).display_name_override, null, 'free text into a public surface waits for an admin')
+})
+
+test('an approved display-name request carries its payload through', async () => {
+ await moderation.requestOrApply({
+ actor: mod, teamId: 1, action: 'display_name_override', payload: { displayName: 'The Old Guard' },
+ })
+ await moderation.decide({ actor: admin, requestId: 1, status: 'approved' })
+ assert.equal(db.teams.get(1).display_name_override, 'The Old Guard')
+})
+
+test('a payload stored as a JSON string is parsed on approval', async () => {
+ // The driver hands JSON columns back parsed on some versions and as a string on
+ // others; an approval that silently applied `undefined` would be a data loss
+ // that only shows up on one of them.
+ await moderation.requestOrApply({
+ actor: mod, teamId: 1, action: 'display_name_override', payload: { displayName: 'Kept' },
+ })
+ db.requests.get(1).payload = JSON.stringify({ displayName: 'Kept' })
+ await moderation.decide({ actor: admin, requestId: 1, status: 'approved' })
+ assert.equal(db.teams.get(1).display_name_override, 'Kept')
+})
+
+test('clearing a display name is gated too', async () => {
+ db.teams.get(1).display_name_override = 'Something'
+ await moderation.requestOrApply({ actor: mod, teamId: 1, action: 'clear_display_name_override' })
+ assert.equal(db.teams.get(1).display_name_override, 'Something')
+
+ await moderation.decide({ actor: admin, requestId: 1, status: 'approved' })
+ assert.equal(db.teams.get(1).display_name_override, null)
+})
+
+// ── Hiding is NOT gated ────────────────────────────────────────────────────
+
+test('a moderator may hide immediately — suppression is always safe', async () => {
+ db.teams.get(1).hidden = 0
+ const result = await moderation.hide({ actor: mod, teamId: 1, reason: 'impersonation' })
+ assert.equal(result.ok, true)
+ assert.equal(db.teams.get(1).hidden, 1)
+ assert.equal(db.teams.get(1).hidden_reason, 'staff')
+ assert.deepEqual(actions(), ['team.hide'])
+ assert.equal(db.requests.size, 0, 'withdrawing untrusted data must not wait for a second pair of eyes')
+})
+
+// ── The gate's scope ───────────────────────────────────────────────────────
+
+test('exactly three actions are gated', () => {
+ assert.deepEqual(moderation.GATED_ACTIONS, ['unhide', 'display_name_override', 'clear_display_name_override'])
+})
+
+test('an action outside the three is rejected rather than quietly gated', async () => {
+ await assert.rejects(
+ () => moderation.requestOrApply({ actor: mod, teamId: 1, action: 'archive' }),
+ /not a gated action/,
+ )
+})
+
+test('every transition writes the audit log', async () => {
+ await moderation.requestOrApply({ actor: mod, teamId: 1, action: 'unhide', reason: 'legit' })
+ await moderation.decide({ actor: admin, requestId: 1, status: 'approved', note: 'checked' })
+
+ assert.equal(logged.length, 3)
+ assert.match(logged[0].detail, /mod1 \(#2\) requested "unhide" on team "Admin" \(#1\): "legit"/)
+ assert.match(logged[2].detail, /root \(#1\) approved request #1/)
+ assert.match(logged[2].detail, /asked by mod1/)
+})
+
+test('the audit trail survives the requester’s account being deleted', async () => {
+ await moderation.requestOrApply({ actor: mod, teamId: 1, action: 'unhide' })
+ // §2.10: requested_by goes SET NULL and the username snapshot is what keeps the
+ // record readable.
+ db.requests.get(1).requested_username = null
+ await moderation.decide({ actor: admin, requestId: 1, status: 'rejected' })
+ assert.match(logged[logged.length - 1].detail, /asked by a deleted user/)
+})
diff --git a/server/test/teamSync.test.js b/server/test/teamSync.test.js
index b2cdfe4..614f986 100644
--- a/server/test/teamSync.test.js
+++ b/server/test/teamSync.test.js
@@ -11,6 +11,7 @@ const assert = require('node:assert/strict')
const registries = require('../src/modules/registries')
const teamsDb = require('../src/model/teams/teams.db')
+const moderation = require('../src/model/teams/teamModeration.model')
const settings = require('../src/model/settings/settings.model')
const teamSync = require('../src/model/teams/teamSync.model')
@@ -169,6 +170,18 @@ function stubDb() {
s.pending_empty_since = since
store.sync.set(moduleId, s)
})
+
+ // Reserved-name screening is its own unit (teamModeration.test.js). Stubbed
+ // here so these tests stay about the reconciler — and because the real calls
+ // read settings, which means a live database connection this suite must never
+ // make. `screened` records that the reconciler asked, which is the integration
+ // point worth asserting from this side.
+ store.screened = []
+ patch(moderation, 'screenForCreate', async (name) => {
+ store.screened.push(name)
+ return { hidden: false }
+ })
+ patch(moderation, 'rescreen', async () => 0)
}
// A provider whose answers the test controls. Defaults are authoritative and
@@ -612,6 +625,58 @@ test('a name with nothing URL-safe in it still gets an address', async () => {
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 () => {
--
2.49.1
From cf2666e5bcd445275be9cd9d4e1eba10b7c2f13e Mon Sep 17 00:00:00 2001
From: wtclaude
Date: Mon, 17 Aug 2026 15:27:02 -0500
Subject: [PATCH 06/35] feat(teams): the Team read API, the moderation routes,
and Admin -> Teams
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The eighteen routes of docs/website/TEAMS.md §2.11, their OpenAPI annotations,
and the staff screen that drives them.
Two rules shape the read model. Hidden means absent from every public surface --
the index, the lookup and the roster alike, and a hidden Team 404s
indistinguishably from one that does not exist, because "absent" includes not
confirming it is there. And staleness is surfaced rather than silent: every
public payload carries { configured, stale, lastSyncAt }, so a page can say how
recently the projection was confirmed instead of presenting stale data as
current.
The public roster withholds both the member key and the user id -- one is a
game-internal identifier, the other names a site account. `linked` answers the
only question a public page has without publishing which account. The module's
per-audience field projection is phase 3's; this is a conservative core one.
The §2.9 gate is enforced per REQUEST, not per route. A moderator may call all
eighteen; three of them mean something different when they do, and the server
decides from the role it re-validates on every request rather than from a token
claim. The client has no "file as request" argument to get wrong.
Found by booting the real server against the real database, and not by any test:
**the index and the by-slug lookup disagreed about what exists.** listPublic was
keyed on a registered team provider while findBySlug is not, so with no module
installed `/teams` returned an empty list while `/teams/:slug/members` served a
full roster -- the index denying a Team that direct URLs answered for in full.
The rows are core's and they outlive the module that filled them: an uninstalled
module leaves a projection that is unmaintained, not one that stopped existing,
and `configured: false` is how a client learns that. The read side no longer
takes the provider into account at all. There is now a test named for the
property.
Also verified live: the public routes answer anonymously, an unknown and a hidden
slug both 404, the player and admin tiers 401 an anonymous caller, a seeded
roster projects correctly, and the reconciler logs that it is staying idle with
no provider registered rather than failing a boot.
Process obligations, all done: #swagger.* annotations on every route, `npm run
swagger` regenerated (18 paths in the spec, no dangling $refs, and the schemas
they reference added), `npm run routes:manifest` regenerated -- additions only,
184 public routes -- and BACKEND_DESIGN.md updated across the schema section and
all three tier tables.
Admin -> Teams follows the ModulesAdmin precedent: everything that decides what a
row SAYS lives in lib/teamAdmin.js, which is plain JS with tests, and the view
renders it. That split earns itself here specifically -- the screen's job is to
make "the shard has no Teams" and "core has not been able to ask for two hours"
impossible to confuse, and those two produce the same empty table. The four
freshness states are named and tested for exactly that reason, and the last
provider error is shown verbatim rather than paraphrased.
The button labels follow the caller's role: a moderator sees "Request publish",
so the pending result is not a surprise. Hiding is offered to everyone with no
gate, matching the server.
Server 894 passed, client 206 passed, client build clean. 17 route tests, 20
client display tests.
Refs docs/website/TEAMS.md §2.11, Part 12 phase 2
Co-Authored-By: Claude
---
client/src/App.jsx | 5 +
client/src/api/client.js | 23 +
client/src/lib/teamAdmin.js | 140 +
client/src/routes/admin/AdminLayout.jsx | 6 +
client/src/routes/admin/views/TeamsAdmin.jsx | 301 ++
client/test/teamAdmin.test.js | 140 +
server/routes.guards.json | 181 +
server/routes.manifest.json | 72 +
server/src/model/teams/teams.db.js | 15 +
server/src/model/teams/teams.model.js | 296 ++
server/src/router/v1/admin/index.js | 6 +
.../src/router/v1/admin/teams.controller.js | 211 ++
server/src/router/v1/admin/teams.router.js | 223 ++
server/src/router/v1/player/index.js | 2 +
.../src/router/v1/player/teams.controller.js | 28 +
server/src/router/v1/player/teams.router.js | 46 +
server/src/router/v1/public/index.js | 5 +
.../src/router/v1/public/teams.controller.js | 48 +
server/src/router/v1/public/teams.router.js | 54 +
server/swagger/swagger-output.json | 3353 +++++++++++++++++
server/swagger/swagger.js | 304 ++
server/test/teamRoutes.test.js | 304 ++
22 files changed, 5763 insertions(+)
create mode 100644 client/src/lib/teamAdmin.js
create mode 100644 client/src/routes/admin/views/TeamsAdmin.jsx
create mode 100644 client/test/teamAdmin.test.js
create mode 100644 server/src/model/teams/teams.model.js
create mode 100644 server/src/router/v1/admin/teams.controller.js
create mode 100644 server/src/router/v1/admin/teams.router.js
create mode 100644 server/src/router/v1/player/teams.controller.js
create mode 100644 server/src/router/v1/player/teams.router.js
create mode 100644 server/src/router/v1/public/teams.controller.js
create mode 100644 server/src/router/v1/public/teams.router.js
create mode 100644 server/test/teamRoutes.test.js
diff --git a/client/src/App.jsx b/client/src/App.jsx
index a627509..1750361 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -42,6 +42,7 @@ import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
import UserDetail from './routes/admin/views/UserDetail.jsx'
import InvitesAdmin from './routes/admin/views/InvitesAdmin.jsx'
import ModulesAdmin from './routes/admin/views/ModulesAdmin.jsx'
+import TeamsAdmin from './routes/admin/views/TeamsAdmin.jsx'
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
import Moderation from './routes/admin/views/Moderation.jsx'
import ModerationUser from './routes/admin/views/ModerationUser.jsx'
@@ -174,6 +175,10 @@ export default function App() {
the volume in the first place. Declared here with the rest of
core's routes, above the module-supplied ones below. */}
} />
+ {/* 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). */}
+ } />
} />
{/* Installed modules' admin pages, at /admin//…, already inside
RequireAuth + AdminLayout. A module cannot supply its own auth
diff --git a/client/src/api/client.js b/client/src/api/client.js
index b21db5a..64359c4 100644
--- a/client/src/api/client.js
+++ b/client/src/api/client.js
@@ -247,6 +247,29 @@ export const api = {
setModuleSources: (hosts) => req('/admin/modules/sources', { method: 'PUT', body: { hosts } }),
restartServer: () => req('/admin/modules/restart', { method: 'POST' }),
+ // Teams (docs/website/TEAMS.md §2.11). Three of these mean something
+ // different depending on who calls them: for a moderator, unhide and
+ // setTeamDisplayName file a request and the response says `pending: true`.
+ // The caller does not choose — the server decides from the live role — so
+ // there is deliberately no "asRequest" argument to get wrong.
+ listTeams: () => req('/admin/teams'),
+ getTeam: (id) => req(`/admin/teams/${id}`),
+ resyncTeams: () => req('/admin/teams/resync', { method: 'POST' }),
+ archiveTeam: (id, reason) => req(`/admin/teams/${id}/archive`, { method: 'POST', body: { reason } }),
+ teamGrants: (id) => req(`/admin/teams/${id}/grants`),
+ hideTeam: (id, reason) => req(`/admin/teams/${id}/hide`, { method: 'POST', body: { reason } }),
+ unhideTeam: (id, reason) => req(`/admin/teams/${id}/unhide`, { method: 'POST', body: { reason } }),
+ setTeamDisplayName: (id, displayName, reason) =>
+ req(`/admin/teams/${id}/display-name`, { method: 'POST', body: { displayName, reason } }),
+ setTeamLeaderOverride: (id, body) =>
+ req(`/admin/teams/${id}/leader-override`, { method: 'POST', body }),
+ clearTeamLeaderOverride: (id, memberKey) =>
+ req(`/admin/teams/${id}/leader-override/${encodeURIComponent(memberKey)}`, { method: 'DELETE' }),
+ teamReviewQueue: () => req('/admin/teams/review'),
+ teamRequests: (status) => req(`/admin/teams/requests${status ? `?status=${status}` : ''}`),
+ decideTeamRequest: (id, status, note) =>
+ req(`/admin/teams/requests/${id}/decide`, { method: 'POST', body: { status, note } }),
+
// ----- moderation dashboard (admin + moderator) -----
modSummary: () => req('/admin/moderation/stats/summary'),
modRecent: (params = {}) => {
diff --git a/client/src/lib/teamAdmin.js b/client/src/lib/teamAdmin.js
new file mode 100644
index 0000000..7c3c34f
--- /dev/null
+++ b/client/src/lib/teamAdmin.js
@@ -0,0 +1,140 @@
+// What Admin → Teams SAYS, separated from how it renders (docs/website/TEAMS.md
+// §2.4, §2.8, §2.9).
+//
+// Plain JS with tests, following lib/moduleAdmin.js. The reason it is worth
+// splitting here specifically: this screen's job is to tell an operator the
+// difference between "the shard has no Teams" and "core has not been able to ask
+// for two hours", and those two produce almost the same page. Getting that
+// wording right is logic, not markup.
+
+/** Tones the screen uses. Names, not colours — the view maps them. */
+export const TONE = { ok: 'ok', warn: 'warn', bad: 'bad', idle: 'idle' }
+
+/**
+ * How to describe the projection's freshness.
+ *
+ * The four states are genuinely different and an operator needs to tell them
+ * apart:
+ *
+ * - no provider registered — nothing to sync, and not a fault;
+ * - never synced — core has an empty projection it has never confirmed, which
+ * must NOT read as "there are no Teams";
+ * - stale — the projection is real but old, and the reason is usually in
+ * `lastError`;
+ * - current.
+ */
+export function freshnessOf(sync = {}) {
+ if (!sync.configured) {
+ return { tone: TONE.idle, label: 'No Team provider', detail: 'No installed module supplies Teams.' }
+ }
+ if (!sync.lastSyncAt) {
+ return {
+ tone: TONE.bad,
+ label: 'Never synced',
+ detail: 'Core has never had an answer it could trust. What is shown below is not a confirmed empty shard.',
+ }
+ }
+ if (sync.stale) {
+ return {
+ tone: TONE.warn,
+ label: 'Stale',
+ detail: `Last confirmed ${ago(sync.lastSyncAt)}. Rosters below may be out of date.`,
+ }
+ }
+ return { tone: TONE.ok, label: 'Current', detail: `Last confirmed ${ago(sync.lastSyncAt)}.` }
+}
+
+/**
+ * A short, human age. Deliberately coarse: this exists so a sentence reads
+ * "confirmed 14 minutes ago", and second-level precision would be false comfort
+ * about a projection whose interval is fifteen minutes.
+ */
+export function ago(value) {
+ if (!value) return 'never'
+ const seconds = Math.max(0, Math.round((Date.now() - new Date(value).getTime()) / 1000))
+ if (seconds < 90) return 'just now'
+ const minutes = Math.round(seconds / 60)
+ if (minutes < 60) return `${minutes} minutes ago`
+ const hours = Math.round(minutes / 60)
+ if (hours < 48) return `${hours} hour${hours === 1 ? '' : 's'} ago`
+ return `${Math.round(hours / 24)} days ago`
+}
+
+/** The status pill for one Team row. */
+export function statusOf(team = {}) {
+ if (team.status === 'archived') {
+ return { tone: TONE.idle, label: team.archivedReason === 'renamed' ? 'Renamed' : 'Archived' }
+ }
+ if (team.hidden && team.hiddenReason === 'reserved_name') {
+ return { tone: TONE.bad, label: 'Hidden — reserved name' }
+ }
+ if (team.hidden) return { tone: TONE.warn, label: 'Hidden by staff' }
+ return { tone: TONE.ok, label: 'Public' }
+}
+
+/**
+ * What a staff member is told will happen when they press the button.
+ *
+ * The gate is decided server-side from the caller's live role, so this only
+ * describes it. Saying "Request" to a moderator and "Apply" to an admin is what
+ * stops the pending result being a surprise.
+ */
+export function gateLabelFor(role, verb) {
+ return role === 'admin' ? verb : `Request ${verb.toLowerCase()}`
+}
+
+/** The three gated actions, for the note under the buttons. */
+export const GATED_NOTE =
+ 'Publishing a game-written name needs an admin: a moderator’s un-hide or display-name change '
+ + 'is filed for approval. Hiding is not gated — suppression is always safe.'
+
+/** A one-line description of a queued request, for the approval queue. */
+export function describeRequest(request = {}) {
+ const payload = parsePayload(request.payload)
+ const who = request.requested_username || 'a deleted user'
+ switch (request.action) {
+ case 'unhide':
+ return `${who} asks to publish “${request.team_name}”`
+ case 'display_name_override':
+ return `${who} asks to display “${request.team_name}” as “${payload.displayName || ''}”`
+ case 'clear_display_name_override':
+ return `${who} asks to clear the display name on “${request.team_name}”`
+ default:
+ return `${who} asks for “${request.action}” on “${request.team_name}”`
+ }
+}
+
+/**
+ * The payload may arrive parsed or as a JSON string depending on the driver, so
+ * this normalises rather than assuming either. The server has the same note.
+ */
+export function parsePayload(payload) {
+ if (payload == null) return {}
+ if (typeof payload === 'object') return payload
+ try {
+ return JSON.parse(payload)
+ } catch {
+ return {}
+ }
+}
+
+/**
+ * How a member's leadership should read.
+ *
+ * An override is shown AS an override rather than folded into the answer: staff
+ * looking at a roster need to see that a decision was made, not a fact that looks
+ * like the game's.
+ */
+export function leadershipOf(member = {}) {
+ if (!member.leaderOverride) {
+ return { isLeader: Boolean(member.isLeader), overridden: false, note: null }
+ }
+ const granted = member.leaderOverride.effect === 'grant'
+ return {
+ isLeader: granted,
+ overridden: true,
+ note: `${granted ? 'Granted' : 'Denied'} by ${member.leaderOverride.by || 'a deleted user'}`
+ + `${member.leaderOverride.reason ? ` — ${member.leaderOverride.reason}` : ''}`
+ + ` (the game says ${member.isLeaderSynced ? 'leader' : 'not a leader'})`,
+ }
+}
diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx
index c7b9136..7c3313d 100644
--- a/client/src/routes/admin/AdminLayout.jsx
+++ b/client/src/routes/admin/AdminLayout.jsx
@@ -76,6 +76,12 @@ export const NAV = [
items: [
{ to: '/admin/moderation', label: 'Moderation', icon: IconShield, roles: ['admin', 'moderator'] },
{ to: '/admin/moderation/appeals', label: 'Appeals', icon: IconShield, roles: ['admin', 'moderator'] },
+ // Moderation rather than System: the screen's daily job is the
+ // reserved-name review queue, which is moderator work. The three actions
+ // that publish a game-written name are gated to admins server-side, so a
+ // moderator reaching this screen is correct — what they do here is file a
+ // request (TEAMS.md §2.9).
+ { to: '/admin/teams', label: 'Teams', icon: IconUsers, roles: ['admin', 'moderator'] },
],
},
{
diff --git a/client/src/routes/admin/views/TeamsAdmin.jsx b/client/src/routes/admin/views/TeamsAdmin.jsx
new file mode 100644
index 0000000..f55dbaf
--- /dev/null
+++ b/client/src/routes/admin/views/TeamsAdmin.jsx
@@ -0,0 +1,301 @@
+import { useCallback, useEffect, useState } from 'react'
+import { Loading, ErrorState } from '../../../components/PageState.jsx'
+import { dateTime } from '../../../lib/format.js'
+import {
+ freshnessOf, statusOf, gateLabelFor, describeRequest, leadershipOf, GATED_NOTE,
+} from '../../../lib/teamAdmin.js'
+import { useAuth } from '../../../contexts/AuthContext.jsx'
+import { api } from '../../../api/client.js'
+
+// Admin → Teams (docs/website/TEAMS.md §2.4, §2.8, §2.9).
+//
+// Three panels, in the order an operator needs them:
+//
+// 1. **Sync state**, verbatim, including the last error. The screen's first job
+// is to make "the shard has no Teams" and "core has not been able to ask for
+// two hours" impossible to confuse — they render almost identically
+// otherwise, and one is fine while the other is an outage.
+// 2. **The review queue** — Teams auto-hidden because their name matched the
+// impersonation list, each showing which term matched.
+// 3. **The approval queue** — what moderators have asked to publish.
+//
+// Everything that decides what a row SAYS lives in lib/teamAdmin.js, which is
+// plain JS and has tests; this file renders it.
+
+const TONE_COLOR = { ok: '#7fd0a4', warn: 'var(--accent)', bad: '#d98b84', idle: 'var(--muted)' }
+
+function Pill({ tone, children }) {
+ return (
+
+ {children}
+
+ )
+}
+
+// ── Sync state ─────────────────────────────────────────────────────────────
+
+function SyncPanel({ sync, syncState, onResync, busy }) {
+ const freshness = freshnessOf(sync)
+ return (
+
+
+
Sync
+ {freshness.label}
+
+
+
{freshness.detail}
+
+ {syncState && (
+
+
Module
{syncState.moduleId}
+
Last attempt
{dateTime(syncState.lastAttemptAt) || 'never'}
+
Last success
{dateTime(syncState.lastSuccessAt) || 'never'}
+
Consecutive failures
{syncState.consecutiveFailures}
+ {syncState.lastError && (
+ <>
+ {/* Verbatim. An operator debugging a stale projection needs what the
+ provider actually said, not a friendlier paraphrase of it. */}
+
+ These Teams are hidden from every public surface because their name matched a reserved term.
+ They work normally for their own members. {GATED_NOTE}
+
+ {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.'}
+
+ >
+ )
+}
diff --git a/client/src/routes/public/Team.jsx b/client/src/routes/public/Team.jsx
new file mode 100644
index 0000000..dad5161
--- /dev/null
+++ b/client/src/routes/public/Team.jsx
@@ -0,0 +1,126 @@
+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 (
+
+
+ {loading && }
+ {error && }
+
+ {!loading && !error && !data && }
+
+ {!loading && !error && data && (
+ <>
+
+
+ {/* 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' && (
+
+ This Team is no longer active.
+ {data.successor && (
+ <>
+ {' '}It is now{' '}
+ {data.successor.name}.
+ >
+ )}
+
+
+ {/* 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. */}
+
+
+
+
+ Recent activity
+
+
+ {feed.loading && }
+ {/* A failed feed is a missing SECTION, never a failed page. */}
+ {feed.error && (
+
+
+ )
+}
diff --git a/client/src/routes/public/TeamRoster.jsx b/client/src/routes/public/TeamRoster.jsx
new file mode 100644
index 0000000..bf7d8cb
--- /dev/null
+++ b/client/src/routes/public/TeamRoster.jsx
@@ -0,0 +1,120 @@
+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 (
+
+
+
+
+
+
+ {/* 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
+
+
+ {member.displayName}
+ {member.isLeader && (
+ Leader
+ )}
+ {/* Muted text alone would read as a rendering glitch; the
+ chip is what makes the gap in the header line legible. */}
+ {!member.linked && (
+
+ not linked
+
+ )}
+
+
{member.rankLabel || '—'}
+
+ {member.online ? 'Online' : 'Offline'}
+
+ {/* 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. */}
+
+
+
+
+ ))}
+
+
+
+ )}
+ >
+ )}
+
+ )
+}
diff --git a/client/src/routes/public/Teams.jsx b/client/src/routes/public/Teams.jsx
new file mode 100644
index 0000000..be50ef3
--- /dev/null
+++ b/client/src/routes/public/Teams.jsx
@@ -0,0 +1,107 @@
+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 (
+
+
+
+ )
+}
diff --git a/client/test/teams.test.js b/client/test/teams.test.js
new file mode 100644
index 0000000..026afab
--- /dev/null
+++ b/client/test/teams.test.js
@@ -0,0 +1,162 @@
+// 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'))
+})
--
2.49.1
From 203ce9c65401a5470a02f2ccb4fe08fe55c089df Mon Sep 17 00:00:00 2001
From: wtclaude
Date: Mon, 17 Aug 2026 20:16:04 -0500
Subject: [PATCH 10/35] chore(teams): regenerate the OpenAPI spec and the route
manifests
`npm run swagger` + `npm run routes:manifest` for the one added route,
`GET /api/v1/public/teams/:slug/activity`, and for `optionalAuth` joining
`/teams/:slug/members`.
The guards manifest names `optionalAuth` on both, which is the point of that
file: a reviewer can see that two public routes now read the caller's identity
without reading the routers.
Co-Authored-By: Claude
---
server/routes.guards.json | 16 +-
server/routes.manifest.json | 4 +
server/swagger/swagger-output.json | 334 ++++++++++++++++++++++++++++-
3 files changed, 349 insertions(+), 5 deletions(-)
diff --git a/server/routes.guards.json b/server/routes.guards.json
index fb4d35e..65b850a 100644
--- a/server/routes.guards.json
+++ b/server/routes.guards.json
@@ -1731,10 +1731,20 @@
},
{
"method": "GET",
- "path": "/api/v1/public/teams/:slug/members",
- "handlers": 2,
+ "path": "/api/v1/public/teams/:slug/activity",
+ "handlers": 3,
"gates": [
- "siteMode"
+ "siteMode",
+ "optionalAuth"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/public/teams/:slug/members",
+ "handlers": 3,
+ "gates": [
+ "siteMode",
+ "optionalAuth"
]
},
{
diff --git a/server/routes.manifest.json b/server/routes.manifest.json
index be09566..db16620 100644
--- a/server/routes.manifest.json
+++ b/server/routes.manifest.json
@@ -705,6 +705,10 @@
"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"
diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json
index 60a8b1b..e0477dc 100644
--- a/server/swagger/swagger-output.json
+++ b/server/swagger/swagger-output.json
@@ -10527,13 +10527,85 @@
}
}
},
+ "/api/v1/public/teams/{slug}/activity": {
+ "get": {
+ "tags": [
+ "Public · Teams"
+ ],
+ "summary": "A Team’s activity feed, filtered to what the caller may see",
+ "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.",
+ "parameters": [
+ {
+ "name": "slug",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "The Team slug."
+ },
+ {
+ "name": "limit",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer"
+ },
+ "description": "Page size, max 100 (default 50)."
+ },
+ {
+ "name": "offset",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer"
+ },
+ "description": "Rows to skip (default 0)."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "One page of the feed",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicTeamActivity"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "No such Team, or it is hidden from this caller",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "503": {
+ "description": "Service Unavailable"
+ }
+ },
+ "security": [
+ {},
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
"/api/v1/public/teams/{slug}/members": {
"get": {
"tags": [
"Public · Teams"
],
"summary": "Get a Team roster",
- "description": "In-game display names only. A member key is a game-internal identifier and a user id names a site account; neither is published. `linked` answers whether a character has an account behind it without saying which.",
+ "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.",
"parameters": [
{
"name": "slug",
@@ -10569,7 +10641,16 @@
"503": {
"description": "Service Unavailable"
}
- }
+ },
+ "security": [
+ {},
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
}
},
"/api/v1/public/version": {
@@ -17081,6 +17162,23 @@
"example": 12
}
}
+ },
+ "enabled": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "boolean"
+ },
+ "description": {
+ "type": "string",
+ "example": "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": {
+ "type": "boolean",
+ "example": true
+ }
+ }
}
}
}
@@ -17225,6 +17323,238 @@
"example": true
}
}
+ },
+ "projected": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "boolean"
+ },
+ "description": {
+ "type": "string",
+ "example": "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": {
+ "type": "boolean",
+ "example": true
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "PublicTeamActivityItem": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "description": {
+ "type": "string",
+ "example": "`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": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "integer"
+ },
+ "example": {
+ "type": "number",
+ "example": 4821
+ }
+ }
+ },
+ "source": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "description": {
+ "type": "string",
+ "example": "`core` or a module id."
+ },
+ "example": {
+ "type": "string",
+ "example": "uo"
+ }
+ }
+ },
+ "kind": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "uo.champion.completed"
+ }
+ }
+ },
+ "summary": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "Completed Champion Neira"
+ }
+ }
+ },
+ "visibility": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "enum": {
+ "type": "array",
+ "example": [
+ "public",
+ "members"
+ ],
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "occurredAt": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "format": {
+ "type": "string",
+ "example": "date-time"
+ }
+ }
+ },
+ "payload": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ },
+ "additionalProperties": {
+ "type": "boolean",
+ "example": true
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "PublicTeamActivity": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "items": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "array"
+ },
+ "items": {
+ "$ref": "#/components/schemas/PublicTeamActivityItem"
+ }
+ }
+ },
+ "total": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "integer"
+ },
+ "description": {
+ "type": "string",
+ "example": "Matching rows for THIS caller’s visibility, so paging is honest."
+ },
+ "example": {
+ "type": "number",
+ "example": 137
+ }
+ }
+ },
+ "limit": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "integer"
+ },
+ "example": {
+ "type": "number",
+ "example": 50
+ }
+ }
+ },
+ "offset": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "integer"
+ },
+ "example": {
+ "type": "number",
+ "example": 0
+ }
+ }
+ },
+ "scope": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "enum": {
+ "type": "array",
+ "example": [
+ "public",
+ "members"
+ ],
+ "items": {
+ "type": "string"
+ }
+ },
+ "description": {
+ "type": "string",
+ "example": "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."
+ }
+ }
}
}
}
--
2.49.1
From 5d9d10b2450b8ded1552df76e0fc3af7d3a952a8 Mon Sep 17 00:00:00 2001
From: wtclaude
Date: Mon, 17 Aug 2026 20:58:07 -0500
Subject: [PATCH 11/35] =?UTF-8?q?refactor(teams)!:=20Teams=20is=20a=20cont?=
=?UTF-8?q?ract,=20not=20a=20surface=20=E2=80=94=20invert=20the=20slots?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Org lead's correction, and it changes what this phase ships.
TEAMS.md §3.1 and §3.5 put four public pages and three nav rows in core. They
should never have been core's. **Teams is the platform primitive that the API
contract exposes; the module builds the pages on top of it.** module-uo builds
guilds; the Rust module that comes next builds clans. Core does not own the word
for a Team, so a core page under a noun core invented would have sat beside
module-uo's existing /uo/guilds saying the same thing in the wrong vocabulary.
Removed: /teams, /teams/:slug, /teams/:slug/roster, /player/teams, the public
and portal nav rows, the `teams` feature flag and the core feature provider that
answered it. /admin/teams stays — an operator inspecting the primitive is
looking at the primitive.
Kept, and unchanged: the tables, the reconciler, the access resolver, the
activity feed, the retention prune, the whole public/player/admin API,
optionalAuth and the roster projection. That is the contract, and it is what
this phase was actually for.
**So the extension slots invert, which is a new direction in MODULE_API §3.7.**
`team.overview` and `team.member.row` assumed core rendered the page. In their
place `registry.declareModuleSlot(id, name)` lets a MODULE declare a place on
its own page and core fill it. Core fills `uo.guild.detail` with the Team
activity feed — the one part of that page core cannot hand over, because only
core can resolve whether the viewer is inside the Team and the public/members
split is a security boundary.
Three things about the inverted direction are load-bearing:
- the name is namespaced under the declaring module and that is enforced, not
conventional: it is the only thing keeping two modules off one name;
- core's fills are applied at MOUNT rather than eagerly. Core's bundle
evaluates before every module chunk, so when core registers a fill the slot
does not exist yet — filling eagerly would silently do nothing;
- a fill for a slot nobody declared is a no-op, never an error. The declaring
module is simply not installed, which is the ordinary case. That is the
opposite of §3.7, where an unknown slot throws, and the asymmetry is real:
there, core declares first, so an unknown name is always a typo.
`Slot` becomes the eighth member of the shared UI kit, so a module renders the
place with core's own error boundary. It matters more here than anywhere else in
the kit: the thing being contained is core's content failing inside the module's
page.
`GET /public/teams/by-external/:moduleId/:externalId` is added because a module
names a Team in its own vocabulary and core keys the feed by slug. The module id
is matched rather than trusted — an external id is unique only within a module.
Co-Authored-By: Claude
---
client/src/App.jsx | 16 --
client/src/api/client.js | 26 ++-
client/src/components/SiteHeader.jsx | 5 -
client/src/lib/teamActivity.js | 100 +++++++++++
client/src/lib/teams.js | 151 ----------------
client/src/main.jsx | 46 ++---
client/src/modules/TeamActivityFeed.jsx | 96 +++++++++++
client/src/modules/coreFeatures.js | 56 ------
client/src/modules/registry.js | 66 +++++++
client/src/modules/shared.js | 8 +
.../src/routes/player/PlayerPortalLayout.jsx | 8 -
client/src/routes/player/PlayerTeams.jsx | 67 --------
client/src/routes/public/Team.jsx | 126 --------------
client/src/routes/public/TeamRoster.jsx | 120 -------------
client/src/routes/public/Teams.jsx | 107 ------------
client/test/moduleRegistry.test.js | 3 +
client/test/moduleSlots.test.js | 76 ++++++++
client/test/teamActivity.test.js | 78 +++++++++
client/test/teams.test.js | 162 ------------------
server/routes.guards.json | 8 +
server/routes.manifest.json | 4 +
server/src/model/teams/teams.model.js | 19 ++
.../src/router/v1/public/teams.controller.js | 21 ++-
server/src/router/v1/public/teams.router.js | 16 ++
server/swagger/swagger-output.json | 54 ++++++
25 files changed, 585 insertions(+), 854 deletions(-)
create mode 100644 client/src/lib/teamActivity.js
delete mode 100644 client/src/lib/teams.js
create mode 100644 client/src/modules/TeamActivityFeed.jsx
delete mode 100644 client/src/modules/coreFeatures.js
delete mode 100644 client/src/routes/player/PlayerTeams.jsx
delete mode 100644 client/src/routes/public/Team.jsx
delete mode 100644 client/src/routes/public/TeamRoster.jsx
delete mode 100644 client/src/routes/public/Teams.jsx
create mode 100644 client/test/teamActivity.test.js
delete mode 100644 client/test/teams.test.js
diff --git a/client/src/App.jsx b/client/src/App.jsx
index 6353cc5..1750361 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -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'
@@ -60,7 +57,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 +93,6 @@ export default function App() {
} />
} />
} />
- {/* 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. */}
- } />
- } />
- } />
{/* Installed modules' public pages, namespaced `//…` — 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
@@ -227,11 +216,6 @@ export default function App() {
} />
} />
} />
- {/* 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. */}
- } />
{/* Installed modules' player-portal pages, at /player//…. This
group's own routes are absolute (its layout route has no path),
so the prefix is written here rather than inherited — the one
diff --git a/client/src/api/client.js b/client/src/api/client.js
index bd19f4d..d35a67e 100644
--- a/client/src/api/client.js
+++ b/client/src/api/client.js
@@ -134,28 +134,24 @@ export const api = {
},
wikiCategories: () => req('/public/wiki/categories'),
- // ----- Teams (TEAMS.md §2.11, §3.1) -----
+ // ----- Teams (TEAMS.md §2.11, §4.3) -----
//
- // 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`),
+ // Only the two calls CORE's own client makes. Core renders no Team pages — the
+ // vocabulary belongs to whichever module owns the surface — so the index, the
+ // roster and the player list are not here; a module that renders those calls
+ // the same public API from its own client.
+ //
+ // The lookup exists because a module names a Team in its own terms and core
+ // keys the feed by slug. Resolving that is core's job precisely so a module
+ // never has to hold core's identifiers.
+ teamByExternalId: (moduleId, externalId) =>
+ req(`/public/teams/by-external/${encodeURIComponent(moduleId)}/${encodeURIComponent(externalId)}`),
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
diff --git a/client/src/components/SiteHeader.jsx b/client/src/components/SiteHeader.jsx
index 064536d..84706f0 100644
--- a/client/src/components/SiteHeader.jsx
+++ b/client/src/components/SiteHeader.jsx
@@ -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' },
diff --git a/client/src/lib/teamActivity.js b/client/src/lib/teamActivity.js
new file mode 100644
index 0000000..2d5c3b6
--- /dev/null
+++ b/client/src/lib/teamActivity.js
@@ -0,0 +1,100 @@
+// What core's Team activity feed SAYS, separated from how it renders
+// (docs/website/TEAMS.md §4.3).
+//
+// Core renders this feed into a slot a MODULE declares on its own page, because
+// Teams is a contract primitive and not a surface: core owns the feed, its
+// visibility rules and its wording; the module owns the page and the vocabulary
+// around it. So this file is deliberately narrow — the roster and index
+// presentation that once lived here went with the core Team pages, to whichever
+// module renders them.
+//
+// Plain JS with tests, following lib/teamAdmin.js. Worth splitting for the same
+// reason it was there: a feed that is filtered, or a projection that is stale,
+// has to say so in words, and getting that wording right is logic rather than
+// markup.
+
+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 surface 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}.` }
+}
+
+/**
+ * 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 entries exist which they cannot have.
+ *
+ * The wording avoids core's own noun. The reader is looking at a page the module
+ * titled — a guild, a clan — and "this Team" would be core's vocabulary leaking
+ * onto a surface that deliberately does not use it.
+ */
+export function activityScopeNote(feed = {}, signedIn = false) {
+ if (feed.scope !== 'public') return null
+ return signedIn
+ ? 'Some entries are visible to members only.'
+ : 'Sign in as a member to see the members-only entries.'
+}
diff --git a/client/src/lib/teams.js b/client/src/lib/teams.js
deleted file mode 100644
index e73a208..0000000
--- a/client/src/lib/teams.js
+++ /dev/null
@@ -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),
- )
-}
diff --git a/client/src/main.jsx b/client/src/main.jsx
index 8ab9ff7..258fbbd 100644
--- a/client/src/main.jsx
+++ b/client/src/main.jsx
@@ -3,8 +3,8 @@ 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, applyCoreFills, fillModuleSlot } from './modules/registry.js'
+import TeamActivityFeed from './modules/TeamActivityFeed.jsx'
import './styles/theme.css'
// Publish window.__rg BEFORE rendering and before any module chunk evaluates.
@@ -19,12 +19,6 @@ 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)
// ── Extension slots (MODULE_API.md §3.7) ───────────────────────────────────
//
@@ -55,24 +49,32 @@ 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
// all three, and core's own fills had to go for it to be able to — the first
// fill wins, and core registered first (§3.7).
+// ── The inverted direction: core fills a MODULE's slot ─────────────────────
+//
+// Teams is a contract PRIMITIVE, not a surface (TEAMS.md Part 3). Core owns the
+// tables, the sync, the access rules and the activity feed; it does not own the
+// word for one — a UO shard says guild, and the module that comes after it will
+// say clan. So core publishes no Team page and no Team nav row, and the module
+// that owns the vocabulary owns the page.
+//
+// The activity feed is the one piece of that page core cannot hand over: only
+// core can resolve whether this viewer is inside the Team, and the public/members
+// split is a security boundary. So the module declares the place and core fills
+// it. Registered here, applied at mount — `applyCoreFills` runs after every
+// module chunk has evaluated, which is the only moment a module-declared slot
+// exists to be filled.
+//
+// Naming a slot no installed module declares is not an error. On a deployment
+// with no game module this fill simply never lands, which is the mirror of an
+// unfilled slot rendering nothing.
+fillModuleSlot('uo.guild.detail', TeamActivityFeed)
+
// Render on DOMContentLoaded rather than immediately, and that is the one line
// of core's boot the module system changes.
//
@@ -99,6 +101,10 @@ declareSlot('team.member.row')
// static deferred script, so this branch is the genuine "the event has already
// been and gone" case and not a wrong guess about our own timing.
function mount() {
+ // Every module chunk has evaluated by now, so any slot a module declared is
+ // present and core's pending fills can land. Must happen before the first
+ // render: `extensionFor` is read during render and there is no subscription.
+ applyCoreFills()
createRoot(document.getElementById('root')).render(
diff --git a/client/src/modules/TeamActivityFeed.jsx b/client/src/modules/TeamActivityFeed.jsx
new file mode 100644
index 0000000..873d098
--- /dev/null
+++ b/client/src/modules/TeamActivityFeed.jsx
@@ -0,0 +1,96 @@
+import { useEffect, useState } from 'react'
+import { api } from '../api/client.js'
+import { useAuth } from '../contexts/AuthContext.jsx'
+import { activityScopeNote, freshnessNote, groupByDay } from '../lib/teamActivity.js'
+
+// Core's Team activity feed, rendered into a slot a MODULE declares
+// (TEAMS.md Part 4, §3.4 as amended).
+//
+// **This is the inverted slot direction, and this component is why it exists.**
+// The feed is core's: core owns `team_activity`, writes the membership and rename
+// items into it, enforces the public/members split, and is the only thing that
+// can resolve whether this viewer is inside the Team. None of that is a module's
+// to reimplement. But the PAGE is the module's, because Teams is a contract
+// primitive and core does not own the word for one — a UO shard says guild, the
+// next game will say something else. So the module declares the place and core
+// puts the feed in it.
+//
+// The module passes the Team in ITS OWN vocabulary — `externalId` plus its module
+// id — and core resolves the slug. A module never learns core's Team id and never
+// needs to: it names the thing the way it already names it.
+//
+// Everything here degrades to rendering nothing. A slot that throws is contained
+// by core's own boundary (Slot.jsx), but a slot that renders an error box would
+// still be core putting a defect on a page it does not own — so a failed fetch is
+// silence, not a message.
+
+export default function TeamActivityFeed({ externalId, moduleId, limit = 25 }) {
+ const { user } = useAuth()
+ const [state, setState] = useState({ loading: true, feed: null, team: null })
+
+ useEffect(() => {
+ let active = true
+ if (!externalId || !moduleId) {
+ setState({ loading: false, feed: null, team: null })
+ return undefined
+ }
+ // Two calls because the module names the Team its way and the feed is keyed
+ // by core's slug. The lookup is core's job precisely so the module does not
+ // have to hold core's identifiers.
+ api.teamByExternalId(moduleId, externalId)
+ .then(async (team) => {
+ const feed = await api.teamActivity(team.slug, { limit })
+ if (active) setState({ loading: false, feed, team })
+ })
+ .catch(() => { if (active) setState({ loading: false, feed: null, team: null }) })
+ return () => { active = false }
+ }, [externalId, moduleId, limit])
+
+ const { loading, feed, team } = state
+ if (loading || !feed) return null
+
+ const days = groupByDay(feed.items || [])
+ const note = team ? freshnessNote(team) : null
+ const scopeNote = activityScopeNote(feed, Boolean(user))
+
+ // Nothing has happened and nothing to explain: render nothing rather than an
+ // empty heading on someone else's page.
+ if (days.length === 0 && !scopeNote) return null
+
+ return (
+
+
+ Recent activity
+
+ {note && (
+
{note.text}
+ )}
+
+ {days.length === 0 && (
+
Nothing has happened here yet.
+ )}
+
+ {days.map((day) => (
+
+
+ {day.label}
+
+
+ {day.items.map((item) => (
+
+ {item.summary}
+
+ ))}
+
+
+ ))}
+
+ {scopeNote && (
+
{scopeNote}
+ )}
+
+ )
+}
diff --git a/client/src/modules/coreFeatures.js b/client/src/modules/coreFeatures.js
deleted file mode 100644
index c797157..0000000
--- a/client/src/modules/coreFeatures.js
+++ /dev/null
@@ -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
diff --git a/client/src/modules/registry.js b/client/src/modules/registry.js
index d6783f5..718bb3f 100644
--- a/client/src/modules/registry.js
+++ b/client/src/modules/registry.js
@@ -135,6 +135,69 @@ export function declareSlot(name) {
slots.set(name, { Component: null, filledBy: null })
}
+/**
+ * The INVERTED direction: a MODULE declares a slot and CORE fills it.
+ *
+ * Added for Teams (TEAMS.md Part 3). The original direction assumes core owns
+ * the page and a module contributes to it, which is right for the footer and the
+ * admin user detail. Teams is the other shape: **Teams is a contract primitive,
+ * not a surface.** Core owns the tables, the sync, the access rules and the
+ * activity feed; it does not own the vocabulary — a UO shard calls them guilds
+ * and the next game will call them something else — so the PAGE is the module's
+ * and the content core contributes to it is core's.
+ *
+ * Without this, core would have to publish a `/teams` page under a word it
+ * invented, next to the module's own Guilds page saying the same thing twice.
+ *
+ * A module namespaces its slot under its own id (`uo.guild.detail`), which is
+ * what stops two modules colliding and what makes the owner readable at the fill
+ * site. The namespace is enforced rather than conventional.
+ *
+ * **Ordering is why this is a separate call and not just `declareSlot` exposed
+ * to modules.** Core's bundle evaluates BEFORE any module chunk (module scripts
+ * are deferred and injected after core's), so at the moment core would like to
+ * fill one of these, it does not exist yet. Core therefore registers its fills
+ * through `fillModuleSlot` below, which is applied after every module chunk has
+ * evaluated — see main.jsx.
+ */
+export function declareModuleSlot(id, name) {
+ if (!name.startsWith(`${id}.`)) {
+ throw new Error(`declareModuleSlot: "${name}" must be namespaced "${id}."`)
+ }
+ if (slots.has(name)) throw new Error(`extension slot "${name}" already declared`)
+ slots.set(name, { Component: null, filledBy: null, declaredBy: id })
+}
+
+// Core's pending fills for module-declared slots, applied once every module
+// chunk has evaluated. Kept as a list rather than applied eagerly because the
+// slot does not exist when core asks — see the ordering note above.
+const coreFills = []
+
+/**
+ * Core: "fill this module-declared slot when it turns up."
+ *
+ * Deliberately not an error when the slot never appears. A module that is not
+ * installed declares nothing, and core offering content for a page that does not
+ * exist is the ordinary case on any deployment — not a misconfiguration. That is
+ * the mirror of an unfilled slot rendering nothing.
+ */
+export function fillModuleSlot(name, Component) {
+ if (typeof Component !== 'function') throw new Error(`fillModuleSlot: ${name} is not a component`)
+ coreFills.push([name, Component])
+}
+
+/** Apply core's fills. Called once from main.jsx, after module chunks have run. */
+export function applyCoreFills() {
+ for (const [name, Component] of coreFills) {
+ const entry = slots.get(name)
+ if (!entry) continue // the declaring module is not installed
+ if (entry.filledBy) continue // a module already claimed it; first fill wins
+ entry.Component = Component
+ entry.filledBy = 'core'
+ }
+ coreFills.length = 0
+}
+
/**
* Fill a declared slot with a component.
*
@@ -207,6 +270,7 @@ export function _reset() {
nav[area].length = 0
}
providers.clear()
+ coreFills.length = 0
// Declarations go too, unlike the server's, where a slot is declared once at
// require time by the router that owns it. Core declares its slots in
// main.jsx — the one file no test loads — so on this side there is nothing
@@ -224,6 +288,8 @@ export const registry = {
registerNav,
registerFeatureProvider,
registerExtension,
+ // The inverted direction (TEAMS.md Part 3): the module declares, core fills.
+ declareModuleSlot,
routesFor,
navFor,
featureProviderFor,
diff --git a/client/src/modules/shared.js b/client/src/modules/shared.js
index 5deb940..6a72734 100644
--- a/client/src/modules/shared.js
+++ b/client/src/modules/shared.js
@@ -34,6 +34,7 @@ import { MODULE_API_VERSION } from './version.js'
import PublicLayout from '../components/PublicLayout.jsx'
import PageHeader from '../components/PageHeader.jsx'
import { Loading, ErrorState, EmptyState } from '../components/PageState.jsx'
+import Slot from './Slot.jsx'
import { useAsync } from '../lib/useAsync.js'
import { useAuth } from '../contexts/AuthContext.jsx'
import { useSite } from '../contexts/SiteContext.jsx'
@@ -66,6 +67,13 @@ const ui = {
useAsync,
useAuth,
useSite,
+ // The eighth member, for the INVERTED slot direction (TEAMS.md Part 3). A
+ // module that declares a slot on its own page needs the same component core
+ // renders its own with — the error boundary in particular, since the thing
+ // being contained here is CORE's content failing inside the MODULE's page.
+ // Shared rather than reimplemented for the reason the whole kit exists: two
+ // boundaries with different behaviour would be two bugs.
+ Slot,
}
// The request PRIMITIVE, not the `api` object (§3.5): a module builds its own
diff --git a/client/src/routes/player/PlayerPortalLayout.jsx b/client/src/routes/player/PlayerPortalLayout.jsx
index cd1c537..12e9153 100644
--- a/client/src/routes/player/PlayerPortalLayout.jsx
+++ b/client/src/routes/player/PlayerPortalLayout.jsx
@@ -35,7 +35,6 @@ function Icon({ children, size = 16 }) {
}
const IconGear = () =>
const IconShield = () =>
-const IconTeams = () =>
// 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 = () => api.myTeams())
- const teams = data?.teams || []
-
- return (
- <>
- {loading && }
- {error && }
-
- {!loading && !error && teams.length === 0 && (
-
- 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.
-
- >
- )
-}
diff --git a/client/src/routes/public/Team.jsx b/client/src/routes/public/Team.jsx
deleted file mode 100644
index dad5161..0000000
--- a/client/src/routes/public/Team.jsx
+++ /dev/null
@@ -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 (
-
-
- {loading && }
- {error && }
-
- {!loading && !error && !data && }
-
- {!loading && !error && data && (
- <>
-
-
- {/* 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' && (
-
- This Team is no longer active.
- {data.successor && (
- <>
- {' '}It is now{' '}
- {data.successor.name}.
- >
- )}
-
-
- {/* 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. */}
-
-
-
-
- Recent activity
-
-
- {feed.loading && }
- {/* A failed feed is a missing SECTION, never a failed page. */}
- {feed.error && (
-
-
- )
-}
diff --git a/client/src/routes/public/TeamRoster.jsx b/client/src/routes/public/TeamRoster.jsx
deleted file mode 100644
index bf7d8cb..0000000
--- a/client/src/routes/public/TeamRoster.jsx
+++ /dev/null
@@ -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 (
-
-
-
-
-
-
- {/* 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
-
-
- {member.displayName}
- {member.isLeader && (
- Leader
- )}
- {/* Muted text alone would read as a rendering glitch; the
- chip is what makes the gap in the header line legible. */}
- {!member.linked && (
-
- not linked
-
- )}
-
-
{member.rankLabel || '—'}
-
- {member.online ? 'Online' : 'Offline'}
-
- {/* 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. */}
-
-
-
-
- ))}
-
-
-
- )}
- >
- )}
-
- )
-}
diff --git a/client/src/routes/public/Teams.jsx b/client/src/routes/public/Teams.jsx
deleted file mode 100644
index be50ef3..0000000
--- a/client/src/routes/public/Teams.jsx
+++ /dev/null
@@ -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 (
-
-
')
+ assert.ok(!stored.includes(' {
+ const stored = cleanForumBody('x')
+ assert.match(stored, /rel="noopener noreferrer nofollow"/)
+ assert.ok(!stored.includes('rel="me"'))
+})
+
+// ── grants: authority, the cap, and non-contamination (§2.5) ───────────────
+
+const team = { id: 1, name: 'Ossuary' }
+const leader = { id: 7, username: 'aldric', role: 'player' }
+const staff = { id: 2, username: 'root', role: 'admin' }
+const guest = { id: 9, username: 'mara', role: 'player' }
+
+function stubGrantWorld({ leaderIds = [7], existing = null, activeCount = 0 } = {}) {
+ patch(access, 'isLeaderByUser', async (_teamId, userId) => leaderIds.includes(userId))
+ patch(accessDb, 'activeGrant', async () => existing)
+ patch(accessDb, 'activeGrantCount', async () => activeCount)
+ patch(usersDb, 'findByUsername', async (name) => (name === guest.username ? guest : null))
+ patch(usersDb, 'findById', async (id) => [leader, staff, guest].find((u) => u.id === id) || null)
+}
+
+test('acceptance 1: a granted account has forum access and is not a member', async () => {
+ stubGrantWorld()
+ const written = []
+ patch(accessDb, 'insertGrant', async (row) => { written.push(row); return 1 })
+ // The membership projection is stubbed to a table nothing may write. If the
+ // grant path touched it, these would be the rows that changed.
+ const membersBefore = []
+ patch(teamsDb, 'membersByTeam', async () => membersBefore)
+ patch(teamsDb, 'activeByUser', async () => undefined)
+
+ const result = await grants.grant({ team, actor: leader, username: 'mara' })
+ assert.equal(result.ok, true)
+ assert.equal(written.length, 1)
+ assert.deepEqual(membersBefore, []) // byte-identical member rows across the cycle
+
+ // The resolver now says yes, and says WHY separately.
+ patch(accessDb, 'activeGrant', async () => ({ user_id: guest.id, granted_by: leader.id }))
+ const resolved = await access.forumAccess(team.id, guest.id)
+ assert.equal(resolved.allowed, true)
+ assert.equal(resolved.viaGrant, true)
+ assert.equal(resolved.viaMembership, false)
+
+ // …and path 4 still refuses, because an integration cannot verify that an
+ // unlinked, forum-granted account is a real game member.
+ assert.equal(await access.externalEligible(team.id, guest.id, 'discord'), false)
+})
+
+test('a leader is capped; staff are not, and are warned on the way past', async () => {
+ stubGrantWorld({ activeCount: 50 })
+ patch(accessDb, 'insertGrant', async () => 1)
+
+ const refused = await grants.grant({ team, actor: leader, username: 'mara' })
+ assert.equal(refused.ok, false)
+ assert.equal(refused.status, 409)
+
+ const allowed = await grants.grant({ team, actor: staff, username: 'mara' })
+ assert.equal(allowed.ok, true)
+ assert.match(allowed.warning, /limit of 50/)
+})
+
+test('a leader may not revoke a staff-issued grant', async () => {
+ stubGrantWorld({ existing: { user_id: guest.id, username: 'mara', granted_by: staff.id } })
+ patch(accessDb, 'revokeGrant', async () => true)
+
+ const refused = await grants.revoke({ team, actor: leader, userId: guest.id })
+ assert.equal(refused.ok, false)
+ assert.equal(refused.status, 403)
+
+ // Staff may. This is what stops a leader undoing a moderation decision.
+ const allowed = await grants.revoke({ team, actor: staff, userId: guest.id })
+ assert.equal(allowed.ok, true)
+})
+
+test('an account that has lost its staff role stops protecting the grants it made', async () => {
+ // Checked at REVOKE time against the issuer's current role, not against a flag
+ // stored when the grant was made — which is the behaviour an operator demoting
+ // someone expects.
+ const demoted = { id: 2, username: 'root', role: 'player' }
+ stubGrantWorld({ existing: { user_id: guest.id, username: 'mara', granted_by: demoted.id } })
+ patch(usersDb, 'findById', async () => demoted)
+ patch(accessDb, 'revokeGrant', async () => true)
+
+ const result = await grants.revoke({ team, actor: leader, userId: guest.id })
+ assert.equal(result.ok, true)
+})
+
+test('a member who is also a grantee is listed as a member, not as a guest', async () => {
+ patch(accessDb, 'activeGrants', async () => [
+ { user_id: 7, username: 'aldric', granted_username: 'root', granted_at: new Date(), reason: null },
+ { user_id: 9, username: 'mara', granted_username: 'root', granted_at: new Date(), reason: null },
+ ])
+ patch(teamsDb, 'membersByTeam', async () => [{ member_key: '0x1', user_id: 7 }])
+
+ const guests = await grants.forumGuests(team.id)
+ assert.deepEqual(guests.map((g) => g.username), ['mara'])
+})
+
+// ── threads (§5.1, §5.3) ───────────────────────────────────────────────────
+
+test('5a creates announcements and refuses discussion threads', async () => {
+ patch(forumDb, 'insertThread', async () => 1)
+ patch(forumDb, 'insertPost', async () => 1)
+
+ const ok = await forum.createThread({ team, actor: leader, type: 'announcement', title: 'Raid', body: '
Hi
' })
+ assert.equal(ok.ok, true)
+
+ // The type exists in the enum from day one so 5b adds no migration — but
+ // nothing creates one yet.
+ const refused = await forum.createThread({ team, actor: leader, type: 'discussion', title: 'Chat', body: '
Hi
' })
+ assert.equal(refused.ok, false)
+ assert.equal(refused.status, 400)
+})
+
+test('an announcement with only markup for a body is refused', async () => {
+ patch(forumDb, 'insertThread', async () => 1)
+ patch(forumDb, 'insertPost', async () => 1)
+ const refused = await forum.createThread({ team, actor: leader, type: 'announcement', title: 'x', body: '' })
+ assert.equal(refused.ok, false)
+})
+
+test('moderation records WHICH authority was exercised', async () => {
+ const ledger = []
+ patch(forumDb, 'threadById', async () => ({ id: 5, team_id: 1, status: 'visible' }))
+ patch(forumDb, 'setThreadFlags', async () => true)
+ patch(forumDb, 'insertModeration', async (row) => { ledger.push(row) })
+
+ await forum.moderateThread({ team, threadId: 5, action: 'lock', actor: leader, actorRole: 'leader' })
+ await forum.moderateThread({ team, threadId: 5, action: 'hide', actor: staff, actorRole: 'staff' })
+
+ assert.deepEqual(ledger.map((r) => r.actorRole), ['leader', 'staff'])
+ assert.deepEqual(ledger.map((r) => r.action), ['lock', 'hide'])
+})
+
+test('a thread id from another Team reads as not found', async () => {
+ patch(forumDb, 'threadById', async () => ({ id: 5, team_id: 999, status: 'visible' }))
+ const result = await forum.getThread(1, 5, { canModerate: true })
+ assert.equal(result, null)
+})
+
+test('a hidden thread is visible to whoever can unhide it, and to nobody else', async () => {
+ patch(forumDb, 'threadById', async () => ({ id: 5, team_id: 1, status: 'hidden', created_by: 7 }))
+ patch(forumDb, 'postsByThread', async () => [])
+ assert.equal(await forum.getThread(1, 5, { canModerate: false }), null)
+ assert.ok(await forum.getThread(1, 5, { canModerate: true }))
+})
+
+// ── uploads (§5.5.4) ───────────────────────────────────────────────────────
+
+test('magic bytes decide the type, not the client’s Content-Type header', () => {
+ const png = Buffer.concat([
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
+ Buffer.alloc(8),
+ ])
+ assert.equal(uploads.sniff(png), 'image/png')
+
+ // A player can send `image/png` with arbitrary bytes. Unrecognised is a
+ // rejection, never a fallback to what the header claimed.
+ assert.equal(uploads.sniff(Buffer.from(' ')), null)
+ assert.equal(uploads.sniff(Buffer.alloc(4)), null) // too short to judge
+})
+
+test('a RIFF container that is not WebP is not accepted as one', () => {
+ const wav = Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('WAVE'), Buffer.alloc(4)])
+ assert.equal(uploads.sniff(wav), null)
+})
diff --git a/server/test/teamRoutes.test.js b/server/test/teamRoutes.test.js
index 4987ed2..7f9b68a 100644
--- a/server/test/teamRoutes.test.js
+++ b/server/test/teamRoutes.test.js
@@ -24,6 +24,10 @@ 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 forumSettings = require('../src/model/teams/teamForumSettings.model')
+const forum = require('../src/model/teams/teamForum.model')
+const grants = require('../src/model/teams/teamGrants.model')
+const access = require('../src/model/teams/teamAccess.model')
const db = require('../src/utils/db')
after(() => db.close())
@@ -302,3 +306,90 @@ test('an empty display name is routed to the CLEAR action, not published as blan
assert.equal(action, 'display_name_override')
})
})
+
+// ── The forum's switch, at the route level (§5.5.1, phase 4) ───────────────
+
+test('acceptance 2: with the forum off every forum route 404s, and nothing is touched', async () => {
+ signInAs(player)
+ patch(forumSettings, 'forumsEnabled', async () => false)
+ // Everything the forum would read or write if the guard failed. None of these
+ // may run: "off means guarded, never destroyed" is a claim about writes as much
+ // as about reads, and a guard that 404s AFTER loading the thread is one that
+ // still bumped a counter on the way.
+ let touched = false
+ const mark = () => { touched = true; return null }
+ patch(teamsDbModule, 'findBySlug', async () => { touched = true; return { id: 1, name: 'A' } })
+ patch(forum, 'listThreads', async () => mark())
+ patch(forum, 'getThread', async () => mark())
+ patch(forum, 'createThread', async () => mark())
+ patch(forum, 'moderateThread', async () => mark())
+
+ await withApp('/api/v1/player', playerRouter, async (app) => {
+ assert.equal((await get(app, '/api/v1/player/teams/a/forum/threads')).status, 404)
+ assert.equal((await get(app, '/api/v1/player/teams/a/forum/threads/1')).status, 404)
+ assert.equal((await post(app, '/api/v1/player/teams/a/forum/threads', { title: 'x', body: 'y' })).status, 404)
+ assert.equal((await post(app, '/api/v1/player/teams/a/forum/threads/1/moderate', { action: 'pin' })).status, 404)
+ })
+ assert.equal(touched, false, 'a guarded route must not read or write the forum on its way to a 404')
+})
+
+test('with the forum ON, the same routes answer — the switch is the only difference', async () => {
+ signInAs(player)
+ patch(forumSettings, 'forumsEnabled', async () => true)
+ patch(forumSettings, 'imageMode', async () => 'disabled')
+ patch(teamsDbModule, 'findBySlug', async () => ({ id: 1, name: 'A' }))
+ patch(access, 'forumAccess', async () => ({ allowed: true, viaMembership: true, viaGrant: false, isLeader: false }))
+ patch(forum, 'listThreads', async () => [])
+
+ await withApp('/api/v1/player', playerRouter, async (app) => {
+ const res = await get(app, '/api/v1/player/teams/a/forum/threads')
+ assert.equal(res.status, 200)
+ const body = await res.json()
+ assert.equal(body.canPost, false, 'an ordinary member does not get the announcement composer')
+ })
+})
+
+test('a caller with no access gets 404, never 403', async () => {
+ // 403 says "this exists and you may not have it", which advertises a private
+ // room to someone outside it. In a forum the contents and the existence are the
+ // same secret.
+ signInAs(player)
+ patch(forumSettings, 'forumsEnabled', async () => true)
+ patch(teamsDbModule, 'findBySlug', async () => ({ id: 1, name: 'A' }))
+ patch(access, 'forumAccess', async () => ({ allowed: false, viaMembership: false, viaGrant: false, isLeader: false }))
+
+ await withApp('/api/v1/player', playerRouter, async (app) => {
+ assert.equal((await get(app, '/api/v1/player/teams/a/forum/threads')).status, 404)
+ })
+})
+
+test('the upload routes 404 in every image mode but uploads', async () => {
+ // The same guard at a second level, for the same reason. An upload control the
+ // client offers and the server refuses is worse than no control — which is why
+ // the mode is published, and why the SERVER is still what enforces it.
+ signInAs(player)
+ patch(forumSettings, 'forumsEnabled', async () => true)
+ patch(forumSettings, 'uploadsEnabled', async () => false)
+ patch(teamsDbModule, 'findBySlug', async () => ({ id: 1, name: 'A' }))
+ patch(access, 'forumAccess', async () => ({ allowed: true, viaMembership: true, viaGrant: false, isLeader: true }))
+
+ await withApp('/api/v1/player', playerRouter, async (app) => {
+ assert.equal((await post(app, '/api/v1/player/teams/a/forum/uploads')).status, 404)
+ })
+})
+
+test('the grant routes answer even while the forum is switched off', async () => {
+ // Deliberate (§5.5.1): a toggle-off revokes no grant and the rows stay
+ // authoritative, so the access list must stay manageable. What the switch
+ // guards is the forum's CONTENT, not its access list.
+ signInAs(player)
+ patch(forumSettings, 'forumsEnabled', async () => false)
+ patch(teamsDbModule, 'findBySlug', async () => ({ id: 1, name: 'A' }))
+ patch(grants, 'authorityFor', async () => ({ may: true, as: 'leader' }))
+ patch(grants, 'forumGuests', async () => [])
+ patch(grants, 'grantCap', async () => 50)
+
+ await withApp('/api/v1/player', playerRouter, async (app) => {
+ assert.equal((await get(app, '/api/v1/player/teams/a/grants')).status, 200)
+ })
+})
--
2.49.1
From 5baada08ef05329ca18e61a5326fd136a0dd230d Mon Sep 17 00:00:00 2001
From: wtclaude
Date: Tue, 18 Aug 2026 09:54:05 -0500
Subject: [PATCH 18/35] fix(teams): four defects the live rig found in the
forum
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
None of these could fail a unit test, and three of them break the feature for the
operator rather than for the code.
**The uploads acknowledgement was a one-way door.** A settings form sends every
field it owns, so once `teams_forum_images` was `uploads`, every later save
re-sent `uploads` — and the gate fired on the VALUE being present rather than on
the mode being SELECTED. The operator could never change a forum setting again,
and the thing they would reach for in a hurry, switching the forum off, was
exactly what came back 400. The gate now passes when an acknowledgement for the
version in force is already on record AND uploads is already the stored mode:
there is no new consent to take. A transition INTO uploads still asks, and a
reworded notice is still caught by assertSettingsWritable.
**An uploaded image could never become a picture.** `uploads` mode hands the
composer `/uploads/.png`, the composer puts it in the body as text — the
author never writes markup, which is the whole design — and the renderer only
rewrites ANCHORS. The linkifier matched absolute http(s) URLs only, so the write
path could not produce the anchor the read path looks for, even though
`isEmbeddableImageUrl` had accepted those paths since the first commit. The two
halves disagreed and only a real upload showed it.
**The embed sat beside its link, not beneath it**, because an is inline, and
nothing capped a remote image to the column — one post from a host serving a
4000px file would have blown the layout out. Core now emits `class="forum-embed"`
and the stylesheet owns both. A class rather than an inline style because the
style would then have to survive the client's DOMPurify pass, and its CSS
sanitiser is a larger thing to reason about than one class name.
**The panel's buttons had no button styling.** `btn-ghost` is a MODIFIER — every
other call site in this codebase pairs it with the base `btn` — so alone it
contributed colours and no geometry, and the controls rendered as bare boxes.
Small inline actions use `pill`, which is what the rest of the admin surface uses
for exactly these. Same class of mistake as the Material one in the Android M12
phase: the modifier carries no base.
Also: the post body now re-sanitises client-side like every other body-HTML
surface on this site, with `ADD_ATTR: ['referrerpolicy']`. That argument is
load-bearing — DOMPurify's default allowlist carries `loading` but not
`referrerpolicy`, so a plain sanitize() call silently strips the one attribute
limiting what a remote embed leaks to the host serving it, which is the privacy
property the admin help text promises.
Co-Authored-By: Claude
---
client/src/modules/TeamForumPanel.jsx | 50 +++++++++-------
client/src/styles/theme.css | 12 ++++
.../model/teams/teamForumSettings.model.js | 33 +++++++---
.../src/router/v1/admin/admin.controller.js | 2 +-
server/src/utils/forumHtml.js | 24 +++++++-
server/test/teamForum.test.js | 60 ++++++++++++++++---
6 files changed, 141 insertions(+), 40 deletions(-)
diff --git a/client/src/modules/TeamForumPanel.jsx b/client/src/modules/TeamForumPanel.jsx
index d961654..a823bdb 100644
--- a/client/src/modules/TeamForumPanel.jsx
+++ b/client/src/modules/TeamForumPanel.jsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
+import DOMPurify from 'dompurify'
import { api } from '../api/client.js'
import { useAuth } from '../contexts/AuthContext.jsx'
import { useSite } from '../contexts/SiteContext.jsx'
@@ -118,7 +119,7 @@ export default function TeamForumPanel({ externalId, moduleId }) {
Announcements
{forum.canPost && !composing && (
-