From 225663d62e65183fac43e5f314e32b3eb30b4a77 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 17 Aug 2026 14:40:26 -0500 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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. */} +
Last error
+
{syncState.lastError}
+ + )} + {syncState.pendingEmptySince && ( + <> +
Empty answer held
+
+ since {dateTime(syncState.pendingEmptySince)} — an authoritative but empty list is + applied only if the next answer agrees. +
+ + )} +
+ )} +
+ ) +} + +// ── The reserved-name review queue ───────────────────────────────────────── + +function ReviewQueue({ rows, role, onAct, busy }) { + if (!rows.length) return null + return ( +
+

Names to review

+

+ These Teams are hidden from every public surface because their name matched a reserved term. + They work normally for their own members. {GATED_NOTE} +

+ + + + + + {rows.map((row) => ( + + + + + + + + ))} + +
NameMatchedMembersCreated
{row.name}{row.hidden_term}{row.member_count}{dateTime(row.created_at)} + +
+
+ ) +} + +// ── The approval queue ───────────────────────────────────────────────────── + +function RequestQueue({ rows, role, onDecide, busy }) { + if (!rows.length) return null + const canDecide = role === 'admin' + return ( +
+

Awaiting approval

+

+ {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.'} +

+
    + {rows.map((row) => ( +
  • + {describeRequest(row)} + {dateTime(row.requested_at)} + {row.reason && “{row.reason}”} + {canDecide && ( + <> + + + + )} +
  • + ))} +
+
+ ) +} + +// ── One Team ─────────────────────────────────────────────────────────────── + +function TeamRow({ team, role, onAct, busy }) { + const status = statusOf(team) + return ( + + + {team.displayName} + {team.displayNameOverride && ( +
+ shown instead of “{team.name}” +
+ )} + + {status.label} + {team.memberCount} + {team.linkedCount} + {team.onlineCount} + {dateTime(team.rosterSyncedAt) || 'never'} + + {team.status === 'active' && (team.hidden + ? ( + + ) + : ( + + ))} + + + ) +} + +// ── The screen ───────────────────────────────────────────────────────────── + +export default function TeamsAdmin() { + const { user } = useAuth() + const role = user ? user.role : null + + const [data, setData] = useState(null) + const [review, setReview] = useState([]) + const [requests, setRequests] = useState([]) + const [error, setError] = useState('') + const [notice, setNotice] = useState('') + const [busy, setBusy] = useState(false) + + const load = useCallback(async () => { + setError('') + try { + const [teams, reviewQueue, requestQueue] = await Promise.all([ + api.admin.listTeams(), + api.admin.teamReviewQueue(), + api.admin.teamRequests('pending'), + ]) + setData(teams) + setReview(reviewQueue.teams || []) + setRequests(requestQueue.requests || []) + } catch (err) { + setError(err.message || 'Could not load Teams.') + } + }, []) + + useEffect(() => { load() }, [load]) + + async function run(fn, pendingMessage) { + setBusy(true) + setNotice('') + setError('') + try { + const result = await fn() + // The server decides whether an action applied or was filed, from the + // caller's live role. Saying so plainly is what stops a moderator thinking + // nothing happened. + if (result && result.pending) setNotice(pendingMessage) + await load() + } catch (err) { + setError(err.message || 'That did not work.') + } finally { + setBusy(false) + } + } + + const act = (id, action) => run( + () => (action === 'hide' ? api.admin.hideTeam(id) : api.admin.unhideTeam(id)), + 'Filed for approval. Nothing has changed publicly until an admin approves it.', + ) + + const decide = (id, status) => run( + () => api.admin.decideTeamRequest(id, status), + '', + ) + + const resync = () => run(async () => { + const result = await api.admin.resyncTeams() + // A refusal is the normal, designed outcome when the provider cannot answer, + // so it is reported as a result rather than thrown as an error. + if (!result.ok) setError(`Resync refused: ${result.reason}. Nothing was changed.`) + else if (result.quarantined) { + setNotice('The provider answered with an empty list. It is being held for confirmation, not applied.') + } + return null + }, '') + + if (error && !data) return + if (!data) return + + return ( +
+

Teams

+ {error && } + {notice &&

{notice}

} + + + + + +
+

All Teams

+ {!data.teams.length && ( +

+ {data.configured + ? 'No Teams in the projection yet.' + : 'No installed module supplies Teams, so there is nothing to show.'} +

+ )} + {data.teams.length > 0 && ( + + + + + + + + {data.teams.map((team) => ( + + ))} + +
NameStatusMembersLinkedOnlineRoster confirmed +
+ )} +
+
+ ) +} + +export { leadershipOf } diff --git a/client/test/teamAdmin.test.js b/client/test/teamAdmin.test.js new file mode 100644 index 0000000..b0e889a --- /dev/null +++ b/client/test/teamAdmin.test.js @@ -0,0 +1,140 @@ +// What Admin → Teams says (client/src/lib/teamAdmin.js). +// +// The test that earns this file: "no Teams" and "core has not been able to ask" +// must never read the same. They produce almost identical screens — an empty +// table — and one is fine while the other is an outage an operator needs to act +// on. Everything else here is in service of that distinction. +import { test } from 'node:test' +import assert from 'node:assert/strict' + +import { + freshnessOf, ago, statusOf, gateLabelFor, describeRequest, parsePayload, leadershipOf, TONE, +} from '../src/lib/teamAdmin.js' + +const minutesAgo = (n) => new Date(Date.now() - n * 60_000).toISOString() + +// ── Freshness: four states that must not be confused ─────────────────────── + +test('no provider is idle, not a fault', () => { + const f = freshnessOf({ configured: false }) + assert.equal(f.tone, TONE.idle) + assert.match(f.label, /No Team provider/) +}) + +test('never synced is reported as never synced, not as an empty shard', () => { + // The failure this prevents: an empty projection core has never confirmed, + // rendered as though the game genuinely has no Teams. + const f = freshnessOf({ configured: true, lastSyncAt: null }) + assert.equal(f.tone, TONE.bad) + assert.equal(f.label, 'Never synced') + assert.match(f.detail, /not a confirmed empty shard/) +}) + +test('stale says how old it is', () => { + const f = freshnessOf({ configured: true, stale: true, lastSyncAt: minutesAgo(14) }) + assert.equal(f.tone, TONE.warn) + assert.equal(f.label, 'Stale') + assert.match(f.detail, /14 minutes ago/) +}) + +test('current says so plainly', () => { + const f = freshnessOf({ configured: true, stale: false, lastSyncAt: minutesAgo(2) }) + assert.equal(f.tone, TONE.ok) + assert.equal(f.label, 'Current') +}) + +test('ago is deliberately coarse', () => { + // Second-level precision would be false comfort about a projection whose poll + // interval is fifteen minutes. + assert.equal(ago(null), 'never') + assert.equal(ago(new Date().toISOString()), 'just now') + assert.equal(ago(minutesAgo(14)), '14 minutes ago') + assert.equal(ago(minutesAgo(60)), '1 hour ago') + assert.equal(ago(minutesAgo(180)), '3 hours ago') + assert.equal(ago(minutesAgo(60 * 72)), '3 days ago') +}) + +// ── Status ───────────────────────────────────────────────────────────────── + +test('the four Team statuses are distinguishable', () => { + assert.equal(statusOf({ status: 'active' }).label, 'Public') + assert.equal(statusOf({ status: 'active', hidden: 1, hiddenReason: 'reserved_name' }).label, 'Hidden — reserved name') + assert.equal(statusOf({ status: 'active', hidden: 1, hiddenReason: 'staff' }).label, 'Hidden by staff') + assert.equal(statusOf({ status: 'archived', archivedReason: 'disbanded' }).label, 'Archived') + assert.equal(statusOf({ status: 'archived', archivedReason: 'renamed' }).label, 'Renamed') +}) + +test('a reserved-name hide is the loudest tone', () => { + assert.equal(statusOf({ status: 'active', hidden: 1, hiddenReason: 'reserved_name' }).tone, TONE.bad) + assert.equal(statusOf({ status: 'active', hidden: 1, hiddenReason: 'staff' }).tone, TONE.warn) +}) + +// ── The gate, described honestly ─────────────────────────────────────────── + +test('the button says what will actually happen for this role', () => { + // The server decides from the live role; this only describes it. Saying + // "Publish" to a moderator would make the pending result a surprise. + assert.equal(gateLabelFor('admin', 'Publish'), 'Publish') + assert.equal(gateLabelFor('moderator', 'Publish'), 'Request publish') +}) + +// ── The approval queue ───────────────────────────────────────────────────── + +test('a request describes itself, including the name being published', () => { + assert.equal( + describeRequest({ action: 'unhide', requested_username: 'mod1', team_name: 'Admin' }), + 'mod1 asks to publish “Admin”', + ) + assert.equal( + describeRequest({ + action: 'display_name_override', requested_username: 'mod1', team_name: 'Admin', + payload: { displayName: 'The Old Guard' }, + }), + 'mod1 asks to display “Admin” as “The Old Guard”', + ) + assert.equal( + describeRequest({ action: 'clear_display_name_override', requested_username: 'mod1', team_name: 'X' }), + 'mod1 asks to clear the display name on “X”', + ) +}) + +test('a deleted requester still reads as a sentence', () => { + // §2.10 sets requested_by to NULL and keeps the username snapshot; when even + // that is gone the queue must not render "null asks to publish". + assert.match(describeRequest({ action: 'unhide', team_name: 'Admin' }), /^a deleted user asks/) +}) + +test('a payload arrives parsed or as a string, and both work', () => { + assert.deepEqual(parsePayload({ displayName: 'X' }), { displayName: 'X' }) + assert.deepEqual(parsePayload('{"displayName":"X"}'), { displayName: 'X' }) + assert.deepEqual(parsePayload(null), {}) + assert.deepEqual(parsePayload('not json'), {}) +}) + +// ── Leadership shows the decision, not just the answer ───────────────────── + +test('an unoverridden member reads straight from the projection', () => { + const l = leadershipOf({ isLeader: true, isLeaderSynced: true }) + assert.equal(l.isLeader, true) + assert.equal(l.overridden, false) + assert.equal(l.note, null) +}) + +test('an override is shown AS an override, with what the game says', () => { + // Staff looking at a roster need to see that a decision was made, not a fact + // that looks like the game's. + const l = leadershipOf({ + isLeaderSynced: true, + leaderOverride: { effect: 'deny', by: 'mod1', reason: 'harassment' }, + }) + assert.equal(l.isLeader, false) + assert.equal(l.overridden, true) + assert.match(l.note, /Denied by mod1 — harassment/) + assert.match(l.note, /the game says leader/) +}) + +test('a grant override says the game disagrees', () => { + const l = leadershipOf({ isLeaderSynced: false, leaderOverride: { effect: 'grant', by: 'root' } }) + assert.equal(l.isLeader, true) + assert.match(l.note, /the game says not a leader/) +}) diff --git a/server/routes.guards.json b/server/routes.guards.json index 05cefc5..fb4d35e 100644 --- a/server/routes.guards.json +++ b/server/routes.guards.json @@ -730,6 +730,145 @@ "validate" ] }, + { + "method": "GET", + "path": "/api/v1/admin/teams", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/teams/:id", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/teams/:id/archive", + "handlers": 4, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/teams/:id/display-name", + "handlers": 5, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/teams/:id/grants", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/teams/:id/hide", + "handlers": 4, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/teams/:id/leader-override", + "handlers": 6, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "DELETE", + "path": "/api/v1/admin/teams/:id/leader-override/:memberKey", + "handlers": 4, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/teams/:id/unhide", + "handlers": 4, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/teams/requests", + "handlers": 3, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/teams/requests/:id/decide", + "handlers": 5, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/teams/resync", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "GET", + "path": "/api/v1/admin/teams/review", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, { "method": "POST", "path": "/api/v1/admin/uploads", @@ -1499,6 +1638,24 @@ "requireAuth" ] }, + { + "method": "GET", + "path": "/api/v1/player/teams", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "GET", + "path": "/api/v1/player/teams/:slug/access", + "handlers": 1, + "gates": [ + "noindex", + "requireAuth" + ] + }, { "method": "POST", "path": "/api/v1/public/contact", @@ -1556,6 +1713,30 @@ "handlers": 1, "gates": [] }, + { + "method": "GET", + "path": "/api/v1/public/teams", + "handlers": 2, + "gates": [ + "siteMode" + ] + }, + { + "method": "GET", + "path": "/api/v1/public/teams/:slug", + "handlers": 2, + "gates": [ + "siteMode" + ] + }, + { + "method": "GET", + "path": "/api/v1/public/teams/:slug/members", + "handlers": 2, + "gates": [ + "siteMode" + ] + }, { "method": "GET", "path": "/api/v1/public/version", diff --git a/server/routes.manifest.json b/server/routes.manifest.json index 3860ae1..be09566 100644 --- a/server/routes.manifest.json +++ b/server/routes.manifest.json @@ -293,6 +293,58 @@ "method": "PUT", "path": "/api/v1/admin/site-mode" }, + { + "method": "GET", + "path": "/api/v1/admin/teams" + }, + { + "method": "GET", + "path": "/api/v1/admin/teams/:id" + }, + { + "method": "POST", + "path": "/api/v1/admin/teams/:id/archive" + }, + { + "method": "POST", + "path": "/api/v1/admin/teams/:id/display-name" + }, + { + "method": "GET", + "path": "/api/v1/admin/teams/:id/grants" + }, + { + "method": "POST", + "path": "/api/v1/admin/teams/:id/hide" + }, + { + "method": "POST", + "path": "/api/v1/admin/teams/:id/leader-override" + }, + { + "method": "DELETE", + "path": "/api/v1/admin/teams/:id/leader-override/:memberKey" + }, + { + "method": "POST", + "path": "/api/v1/admin/teams/:id/unhide" + }, + { + "method": "GET", + "path": "/api/v1/admin/teams/requests" + }, + { + "method": "POST", + "path": "/api/v1/admin/teams/requests/:id/decide" + }, + { + "method": "POST", + "path": "/api/v1/admin/teams/resync" + }, + { + "method": "GET", + "path": "/api/v1/admin/teams/review" + }, { "method": "POST", "path": "/api/v1/admin/uploads" @@ -605,6 +657,14 @@ "method": "GET", "path": "/api/v1/player/appeals/eligible" }, + { + "method": "GET", + "path": "/api/v1/player/teams" + }, + { + "method": "GET", + "path": "/api/v1/player/teams/:slug/access" + }, { "method": "POST", "path": "/api/v1/public/contact" @@ -637,6 +697,18 @@ "method": "GET", "path": "/api/v1/public/status" }, + { + "method": "GET", + "path": "/api/v1/public/teams" + }, + { + "method": "GET", + "path": "/api/v1/public/teams/:slug" + }, + { + "method": "GET", + "path": "/api/v1/public/teams/:slug/members" + }, { "method": "GET", "path": "/api/v1/public/version" diff --git a/server/src/model/teams/teams.db.js b/server/src/model/teams/teams.db.js index f7f3c27..ce4648c 100644 --- a/server/src/model/teams/teams.db.js +++ b/server/src/model/teams/teams.db.js @@ -24,6 +24,20 @@ async function activeByModule(moduleId) { ) } +/** + * Every ACTIVE team, whichever module owns it. + * + * For the READ side, which must not be keyed on a provider being registered. The + * rows are core's and they outlive the module that filled them — a module + * uninstalled or disabled leaves a projection that is unmaintained, not one that + * stopped existing. Listing by provider made `/teams` empty while + * `/teams/:slug/members` still answered in full, since the lookup goes by slug: + * the index denied a Team that direct URLs served. + */ +async function allActive() { + return query(`SELECT ${TEAM_COLUMNS} FROM teams WHERE status = 'active' ORDER BY id`) +} + /** The ACTIVE row for an external id, or undefined. At most one, by uq_teams_active. */ async function findActive(moduleId, externalId) { const rows = await query( @@ -270,6 +284,7 @@ async function setPendingEmpty(moduleId, since) { module.exports = { activeByModule, + allActive, findActive, findById, findBySlug, diff --git a/server/src/model/teams/teams.model.js b/server/src/model/teams/teams.model.js new file mode 100644 index 0000000..7f09dae --- /dev/null +++ b/server/src/model/teams/teams.model.js @@ -0,0 +1,296 @@ +// ── The Team read model ──────────────────────────────────────────────────── +// +// What the three API tiers are allowed to see (TEAMS.md §2.11), assembled from +// the projection, the resolver and the sync state. +// +// **Two rules shape every function here.** +// +// 1. *Hidden means absent from every public surface* (§2.8.3) — the index, the +// lookup, the roster. Not archived, not deleted, and completely functional for +// its own members. A hidden Team that 404s publicly but answers for a member +// is the intended behaviour, not an inconsistency. +// +// 2. *Staleness is surfaced, never silent* (§2.4). Every public payload carries +// `{ stale, lastSyncAt }`, so a page can say "roster last confirmed 14 minutes +// ago" rather than presenting a stale roster as current. A projection nobody +// can tell is stale is worse than one that is obviously old. +// +// The per-audience FIELD projection of a roster row is the module's, not core's +// (§10.5, §3.3) — the visibility framework and its config are module-owned. This +// phase serves a conservative core projection: a public roster carries in-game +// display names and never a site account id or a game member key. The module's +// rung-aware projection lands with the Team pages in phase 3. + +const teamsDb = require('./teams.db') +const teamProvider = require('./teamProvider') +const access = require('./teamAccess.model') +const teamSync = require('./teamSync.model') + +// Past this multiple of the poll interval a projection is reported stale. Two +// intervals rather than one, so an ordinary late poll does not make every page +// cry wolf — the threshold has to mean "something is wrong", not "a run is due". +const STALE_INTERVALS = 2 + +/** The public shape of a Team. Deliberately small. */ +function publicTeam(row) { + return { + slug: row.slug, + // What is DISPLAYED may have been overridden by staff; what the row IS never + // changes (§2.2, §2.8.3). Public callers only ever see the former. + name: row.display_name_override || row.name, + abbr: row.abbr, + memberCount: row.member_count, + linkedCount: row.linked_count, + onlineCount: row.online_count, + meta: row.meta ?? null, + status: row.status, + createdAt: row.created_at, + rosterSyncedAt: row.roster_synced_at, + ...(row.status === 'archived' ? { archivedAt: row.archived_at, archivedReason: row.archived_reason } : {}), + } +} + +/** + * The public shape of a roster row. + * + * `member_key` and `user_id` are both withheld: the first is a game-internal + * identifier and the second names a site account. `linked` answers the only + * question a public page has — whether this character has an account behind it — + * without publishing which one. + */ +function publicMember(row) { + return { + displayName: row.display_name, + rankLabel: row.rank_label, + isLeader: Boolean(row.is_leader), + online: Boolean(row.online), + linked: row.user_id != null, + } +} + +/** The admin shape: everything, including what a decision overrode. */ +function adminTeam(row) { + return { + id: row.id, + moduleId: row.module_id, + externalId: row.external_id, + slug: row.slug, + name: row.name, + displayName: row.display_name_override || row.name, + displayNameOverride: row.display_name_override, + abbr: row.abbr, + status: row.status, + hidden: Boolean(row.hidden), + hiddenReason: row.hidden_reason, + hiddenTerm: row.hidden_term, + nameReviewedAt: row.name_reviewed_at, + memberCount: row.member_count, + linkedCount: row.linked_count, + onlineCount: row.online_count, + rosterSyncedAt: row.roster_synced_at, + membersEmptySince: row.members_empty_since, + succeededBy: row.succeeded_by, + createdAt: row.created_at, + archivedAt: row.archived_at, + archivedReason: row.archived_reason, + meta: row.meta ?? null, + } +} + +function adminMember(row) { + return { + memberKey: row.member_key, + displayName: row.display_name, + userId: row.user_id, + rankLabel: row.rank_label, + isLeader: Boolean(row.is_leader), + isLeaderSynced: Boolean(row.is_leader_synced), + leaderOverride: row.leader_override || null, + online: Boolean(row.online), + status: row.status, + firstSeenAt: row.first_seen_at, + lastSeenAt: row.last_seen_at, + departedAt: row.departed_at, + } +} + +/** + * Freshness, as every public payload reports it. + * + * With no provider registered there is nothing to be stale ABOUT, so this reports + * `stale: false` and a null timestamp rather than "very stale" — a deployment + * with no game module is not a broken one. + */ +async function syncStatus() { + const moduleId = teamProvider.providerModuleId() + if (!moduleId) return { stale: false, lastSyncAt: null, configured: false } + + const [state, intervalS] = await Promise.all([ + teamsDb.syncState(moduleId), + teamSync.intervalSeconds(), + ]) + const lastSyncAt = state ? state.last_success_at : null + const ageS = lastSyncAt ? (Date.now() - new Date(lastSyncAt).getTime()) / 1000 : Infinity + return { + configured: true, + lastSyncAt, + // Never synced at all is stale: a page must not present an empty projection + // as a confirmed empty shard. + stale: ageS > intervalS * STALE_INTERVALS, + consecutiveFailures: state ? state.consecutive_failures : 0, + } +} + +// ── Public ───────────────────────────────────────────────────────────────── + +async function listPublic({ limit = 50, offset = 0 } = {}) { + // Every active Team, not just the registered provider's. The rows are core's + // and they outlive the module that filled them: keying the index on a provider + // made an uninstalled module's Teams vanish from /teams while + // /teams/:slug/members still served them in full, because the lookup goes by + // slug. `configured: false` is how a client learns the projection is no longer + // being maintained -- an empty list would have said something untrue instead. + const [rows, sync] = await Promise.all([teamsDb.allActive(), syncStatus()]) + const visible = rows.filter((r) => !r.hidden) + return { + teams: visible.slice(offset, offset + limit).map(publicTeam), + total: visible.length, + ...sync, + } +} + +/** + * One Team by slug, for a public caller. + * + * An ARCHIVED Team resolves rather than 404ing (§2.2): a bookmark or a Discord + * link from before a rename must land somewhere that explains itself. A HIDDEN + * one does not resolve at all — that is the difference between retired and + * suppressed. + */ +async function getPublic(slug) { + const row = await teamsDb.findBySlug(slug) + if (!row || row.hidden) return null + const sync = await syncStatus() + const successor = row.succeeded_by ? await teamsDb.findById(row.succeeded_by) : null + return { + ...publicTeam(row), + ...sync, + successor: successor && !successor.hidden + ? { slug: successor.slug, name: successor.display_name_override || successor.name } + : null, + } +} + +async function rosterPublic(slug) { + const row = await teamsDb.findBySlug(slug) + if (!row || row.hidden) return null + const [members, sync] = await Promise.all([ + access.rosterWithOverrides(row.id), + syncStatus(), + ]) + return { members: members.map(publicMember), ...sync, rosterSyncedAt: row.roster_synced_at } +} + +// ── Player ───────────────────────────────────────────────────────────────── + +/** + * The caller's Teams — membership and grants — each with the REASON it is listed. + * + * The two are read from their own tables and merged here rather than by a query + * that unions them, so the reason survives into the payload. `both` is a real + * state and the UI needs it: a member who also holds a historical grant should + * see membership as the current reason without the grant vanishing. + * + * A hidden Team IS listed here. Suppression is a public-surface rule; a member is + * not a member of the public. + */ +async function listForUser(userId) { + const memberships = await teamsDb.activeTeamsForUser(userId) + const byId = new Map() + + for (const row of memberships) { + byId.set(row.id, { ...publicTeam(row), reason: 'membership', isLeader: Boolean(row.is_leader) }) + } + + // Grants are per Team, so the visible set is walked rather than queried the + // other way round; the population is small (a user's Teams), and it keeps path + // 3's read on path 3's table. + const all = await teamsDb.allActive() + for (const row of all) { + // eslint-disable-next-line no-await-in-loop + const resolved = await access.forumAccess(row.id, userId) + if (!resolved.viaGrant) continue + const existing = byId.get(row.id) + if (existing) existing.reason = 'both' + else byId.set(row.id, { ...publicTeam(row), reason: 'grant', isLeader: false }) + } + + return { teams: [...byId.values()], ...(await syncStatus()) } +} + +/** The caller's own resolved access on one Team. */ +async function accessForUser(slug, userId) { + const row = await teamsDb.findBySlug(slug) + if (!row) return null + const resolved = await access.forumAccess(row.id, userId) + return { slug: row.slug, ...resolved } +} + +// ── Admin ────────────────────────────────────────────────────────────────── + +async function listAdmin({ includeArchived = false } = {}) { + const moduleId = teamProvider.providerModuleId() + const rows = await teamsDb.allActive() + const sync = await syncStatus() + const state = moduleId ? await teamsDb.syncState(moduleId) : null + return { + teams: rows.map(adminTeam), + ...sync, + // Shown verbatim on Admin → Teams, including the last error: an operator + // debugging a stale projection needs what the provider actually said. + syncState: state + ? { + moduleId: state.module_id, + lastAttemptAt: state.last_attempt_at, + lastSuccessAt: state.last_success_at, + consecutiveFailures: state.consecutive_failures, + lastError: state.last_error, + pendingEmptySince: state.pending_empty_since, + } + : null, + includeArchived, + } +} + +async function getAdmin(id) { + const row = await teamsDb.findById(id) + if (!row) return null + const [members, grants, pending] = await Promise.all([ + access.rosterWithOverrides(row.id, { includeDeparted: true }), + access.grantLedger(row.id), + // eslint-disable-next-line global-require + require('./teamModeration.model').pendingForTeam(row.id), + ]) + return { + ...adminTeam(row), + members: members.map(adminMember), + grants, + pendingRequests: pending, + } +} + +module.exports = { + listPublic, + getPublic, + rosterPublic, + listForUser, + accessForUser, + listAdmin, + getAdmin, + syncStatus, + publicTeam, + publicMember, + adminTeam, + adminMember, + STALE_INTERVALS, +} diff --git a/server/src/router/v1/admin/index.js b/server/src/router/v1/admin/index.js index 24c5fed..cc3e9aa 100644 --- a/server/src/router/v1/admin/index.js +++ b/server/src/router/v1/admin/index.js @@ -31,6 +31,7 @@ const emailRouter = require('./email.router') const discordBotRouter = require('./discordBot.router') const settingsRouter = require('./settings.router') const modulesRouter = require('./modules.router') +const teamsRouter = require('./teams.router') const dashboardRouter = require('./dashboard.router') const adminRouter = express.Router() @@ -79,6 +80,11 @@ adminRouter.use('/settings', settingsRouter) // here alongside the other configuration capabilities, and admin-only per route // rather than at this line, so the gate sits next to what it is guarding. adminRouter.use('/modules', modulesRouter) +// Teams. Staff-wide, like /activity: a moderator runs the reserved-name review +// queue. The three actions that PUBLISH untrusted game-sourced strings are gated +// per request inside the controller, not per route — a moderator may call them, +// and calling them files a request rather than applying one (TEAMS.md §2.9). +adminRouter.use('/teams', teamsRouter) // The two singletons that own no path segment of their own: GET /dashboard and // PUT /site-mode. Mounted at the group root, last, exactly where the residual diff --git a/server/src/router/v1/admin/teams.controller.js b/server/src/router/v1/admin/teams.controller.js new file mode 100644 index 0000000..00627f1 --- /dev/null +++ b/server/src/router/v1/admin/teams.controller.js @@ -0,0 +1,211 @@ +// Admin · Teams — the staff surface (TEAMS.md §2.11). +// +// The role split inside this file is the §2.9 gate, and it is enforced HERE +// rather than in the router, because it is not a matter of which routes a role +// may call: a moderator may call all of them, and three of them mean something +// different when they do. `requestOrApply` is what decides, from the caller's +// live role, whether an action applies or is filed for approval. + +const teams = require('../../../model/teams/teams.model') +const moderation = require('../../../model/teams/teamModeration.model') +const access = require('../../../model/teams/teamAccess.model') +const teamSync = require('../../../model/teams/teamSync.model') +const teamsDb = require('../../../model/teams/teams.db') +const activity = require('../../../model/activity/activity.model') + +const log = require('../../../utils/logger')('teams') + +const fail = (res, err, what) => { + log.error(`admin teams: ${what} failed`, { message: err.message }) + return res.status(500).json({ message: 'Internal Server Error' }) +} + +/** Translate a model result's { ok, status, error } into a response. */ +const send = (res, result, body = { ok: true }) => + (result.ok ? res.json({ ...body, ...result }) : res.status(result.status || 400).json({ message: result.error })) + +async function listTeams(req, res) { + try { + return res.json(await teams.listAdmin({ includeArchived: req.query.archived === '1' })) + } catch (err) { + return fail(res, err, 'list') + } +} + +async function getTeam(req, res) { + try { + const team = await teams.getAdmin(Number(req.params.id)) + if (!team) return res.status(404).json({ message: 'Team not found' }) + return res.json(team) + } catch (err) { + return fail(res, err, 'get') + } +} + +/** + * The operator's escape hatch. + * + * Awaited rather than fire-and-forget: someone who pressed a button is owed the + * outcome, including the provider's error when it refused. `ctx.teams.reconcile()` + * is the debounced, unawaited path — this is not that. + */ +async function resync(req, res) { + try { + const result = await teamSync.reconcileNow('admin') + await activity.log({ req, action: 'team.resync', detail: `${req.user.username} (#${req.user.id}) ran a Team resync` }) + return res.json(result) + } catch (err) { + return fail(res, err, 'resync') + } +} + +async function archive(req, res) { + try { + const id = Number(req.params.id) + const team = await teamsDb.findById(id) + if (!team) return res.status(404).json({ message: 'Team not found' }) + await teamsDb.archiveTeam(id, 'staff') + await activity.log({ + req, + action: 'team.archive', + detail: `${req.user.username} (#${req.user.id}) archived team "${team.name}" (#${id})` + + `${req.body.reason ? `: "${req.body.reason}"` : ''}`, + }) + return res.json({ ok: true }) + } catch (err) { + return fail(res, err, 'archive') + } +} + +async function grants(req, res) { + try { + return res.json({ grants: await access.grantLedger(Number(req.params.id)) }) + } catch (err) { + return fail(res, err, 'grants') + } +} + +// ── Leadership overrides (§2.5.1) — NOT gated ───────────────────────────── + +async function setLeaderOverride(req, res) { + try { + const id = Number(req.params.id) + const team = await teamsDb.findById(id) + if (!team) return res.status(404).json({ message: 'Team not found' }) + + const { memberKey, effect, reason } = req.body + await access.setLeaderOverride({ + teamId: id, + memberKey, + effect, + actorUserId: req.user.id, + actorUsername: req.user.username, + reason: reason || null, + }) + await activity.log({ + req, + action: 'team.leader.override', + detail: `${req.user.username} (#${req.user.id}) set a "${effect}" leadership override on ` + + `${memberKey} in team "${team.name}" (#${id})${reason ? `: "${reason}"` : ''}`, + }) + return res.json({ ok: true }) + } catch (err) { + return fail(res, err, 'leader-override') + } +} + +async function clearLeaderOverride(req, res) { + try { + const id = Number(req.params.id) + const removed = await access.clearLeaderOverride(id, req.params.memberKey) + if (!removed) return res.status(404).json({ message: 'No such override' }) + await activity.log({ + req, + action: 'team.leader.override', + detail: `${req.user.username} (#${req.user.id}) cleared the leadership override on ` + + `${req.params.memberKey} in team #${id}`, + }) + return res.json({ ok: true }) + } catch (err) { + return fail(res, err, 'leader-override') + } +} + +// ── The three gated actions, plus the ungated hide (§2.9) ───────────────── + +async function unhide(req, res) { + try { + return send(res, await moderation.requestOrApply({ + req, actor: req.user, teamId: Number(req.params.id), action: 'unhide', reason: req.body.reason, + })) + } catch (err) { + return fail(res, err, 'unhide') + } +} + +async function hide(req, res) { + try { + return send(res, await moderation.hide({ + req, actor: req.user, teamId: Number(req.params.id), reason: req.body.reason, + })) + } catch (err) { + return fail(res, err, 'hide') + } +} + +async function displayName(req, res) { + try { + const { displayName: value, reason } = req.body + // An empty string is how a UI says "clear it", and clearing is its own gated + // action rather than an override set to nothing — otherwise the audit line + // would read as though someone published a blank name. + const action = value ? 'display_name_override' : 'clear_display_name_override' + return send(res, await moderation.requestOrApply({ + req, actor: req.user, teamId: Number(req.params.id), action, payload: { displayName: value || null }, reason, + })) + } catch (err) { + return fail(res, err, 'display-name') + } +} + +async function reviewQueue(req, res) { + try { + return res.json({ teams: await moderation.reviewQueue() }) + } catch (err) { + return fail(res, err, 'review queue') + } +} + +async function listRequests(req, res) { + try { + return res.json({ requests: await moderation.listRequests({ status: req.query.status || 'pending' }) }) + } catch (err) { + return fail(res, err, 'requests') + } +} + +async function decideRequest(req, res) { + try { + return send(res, await moderation.decide({ + req, actor: req.user, requestId: Number(req.params.id), status: req.body.status, note: req.body.note, + })) + } catch (err) { + return fail(res, err, 'decide') + } +} + +module.exports = { + listTeams, + getTeam, + resync, + archive, + grants, + setLeaderOverride, + clearLeaderOverride, + unhide, + hide, + displayName, + reviewQueue, + listRequests, + decideRequest, +} diff --git a/server/src/router/v1/admin/teams.router.js b/server/src/router/v1/admin/teams.router.js new file mode 100644 index 0000000..33d2715 --- /dev/null +++ b/server/src/router/v1/admin/teams.router.js @@ -0,0 +1,223 @@ +// Admin · Teams — sync state, the review queue, the approval queue, and the staff +// actions on a Team (TEAMS.md §2.11). +// +// Mounted at /api/v1/admin/teams by admin/index.js, which already applied +// `noindex, isLoggedIn, staffOnly`. Staff-wide, like /admin/activity: a moderator +// runs the review queue, and the three actions that PUBLISH untrusted +// game-sourced strings are gated per request inside the controller rather than +// per route here — a moderator may call them, and calling them files a request +// instead of applying one. +// +// **Declaration order matters in this file.** `/review`, `/requests` and `/resync` +// are literal paths that would otherwise be captured by `/:id`, so every literal +// route is declared before the first :param route. Express is first-match-wins and +// a `/:id` ahead of `/review` would silently turn a queue into a lookup for a Team +// whose id is "review". + +const express = require('express') +const { body, param, query } = require('express-validator') + +const ctrl = require('./teams.controller') +const validate = require('../../../middleware/validate') + +const teamsRouter = express.Router() + +// ── Literal paths, first ─────────────────────────────────────────────────── + +teamsRouter.get( + '/', + // #swagger.tags = ['Admin · Teams'] + // #swagger.summary = 'List Teams with sync state' + // #swagger.description = 'Includes hidden Teams and the module’s sync state verbatim — last attempt, last success, consecutive failures and the last error — which is what an operator debugging a stale projection needs.' + // #swagger.parameters['archived'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Set to 1 to include archived Teams.' } + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Teams and sync state', content: { "application/json": { schema: { $ref: "#/components/schemas/AdminTeamList" } } } } */ + query('archived').optional().isIn(['0', '1']), + validate, + ctrl.listTeams, +) + +teamsRouter.post( + '/resync', + // #swagger.tags = ['Admin · Teams'] + // #swagger.summary = 'Run a reconciliation now' + // #swagger.description = 'Awaited, so the response carries the outcome including the provider’s own error when it refused. The four refusal gates still apply — a manual resync cannot make core act on an answer it does not trust.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'The reconciliation result', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamResyncResult" } } } } */ + ctrl.resync, +) + +teamsRouter.get( + '/review', + // #swagger.tags = ['Admin · Teams'] + // #swagger.summary = 'The reserved-name review queue' + // #swagger.description = 'Teams auto-hidden because their name matched a reserved term, each showing which term matched. A Team a human has already ruled on leaves the queue and is never re-hidden by a later sweep.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Auto-hidden Teams awaiting review', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamReviewQueue" } } } } */ + ctrl.reviewQueue, +) + +teamsRouter.get( + '/requests', + // #swagger.tags = ['Admin · Teams'] + // #swagger.summary = 'The moderation approval queue' + // #swagger.description = 'Requests filed by moderators for the three actions that publish untrusted game-sourced strings. Decided rows are kept — the record that a moderator asked to publish a name and an admin refused is the part worth having.' + // #swagger.parameters['status'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'pending (default) | approved | rejected | withdrawn | all' } + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Moderation requests', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamRequestQueue" } } } } */ + query('status').optional().isIn(['pending', 'approved', 'rejected', 'withdrawn', 'all']), + validate, + ctrl.listRequests, +) + +teamsRouter.post( + '/requests/:id/decide', + // #swagger.tags = ['Admin · Teams'] + // #swagger.summary = 'Approve or reject a moderation request (admin only)' + // #swagger.description = 'Admin only, checked live against the database rather than from a token claim. Approving applies the action; rejecting keeps the row and changes nothing. A request already decided returns 409, so two admins deciding at once cannot double-apply.' + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Request id.' } + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamDecideRequest" } } } } */ + /* #swagger.responses[200] = { description: 'Decided', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */ + /* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */ + /* #swagger.responses[403] = { description: 'Only an admin may decide a request', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[404] = { description: 'No such request', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[409] = { description: 'Already decided', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt({ min: 1 }).toInt(), + body('status').isIn(['approved', 'rejected']), + body('note').optional().isString().trim().isLength({ max: 255 }), + validate, + ctrl.decideRequest, +) + +// ── :id paths ────────────────────────────────────────────────────────────── + +teamsRouter.get( + '/:id', + // #swagger.tags = ['Admin · Teams'] + // #swagger.summary = 'Get one Team, with its roster, grant ledger and pending requests' + // #swagger.description = 'The roster carries the resolved leadership and what the game actually said, so an override is visible as a decision rather than presented as fact. Departed members are included.' + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' } + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'The Team', content: { "application/json": { schema: { $ref: "#/components/schemas/AdminTeam" } } } } */ + /* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt({ min: 1 }).toInt(), + validate, + ctrl.getTeam, +) + +teamsRouter.get( + '/:id/grants', + // #swagger.tags = ['Admin · Teams'] + // #swagger.summary = 'The full forum-grant ledger for a Team, revoked rows included' + // #swagger.description = 'The structured record the access resolver reads. The grant/revoke flow itself lands in the forum phase; this is the read side.' + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' } + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'The grant ledger', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamGrantLedger" } } } } */ + param('id').isInt({ min: 1 }).toInt(), + validate, + ctrl.grants, +) + +teamsRouter.post( + '/:id/archive', + // #swagger.tags = ['Admin · Teams'] + // #swagger.summary = 'Archive a Team (staff)' + // #swagger.description = 'Not gated: archiving withdraws a Team from public surfaces rather than publishing anything.' + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' } + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: false, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamReasonRequest" } } } } */ + /* #swagger.responses[200] = { description: 'Archived', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */ + /* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt({ min: 1 }).toInt(), + body('reason').optional().isString().trim().isLength({ max: 255 }), + validate, + ctrl.archive, +) + +teamsRouter.post( + '/:id/hide', + // #swagger.tags = ['Admin · Teams'] + // #swagger.summary = 'Hide a Team from public surfaces (staff)' + // #swagger.description = 'Deliberately NOT gated. Publishing untrusted data needs a second pair of eyes; withdrawing it needs to be possible at once, by whoever is on duty.' + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' } + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: false, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamReasonRequest" } } } } */ + /* #swagger.responses[200] = { description: 'Hidden', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */ + /* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt({ min: 1 }).toInt(), + body('reason').optional().isString().trim().isLength({ max: 255 }), + validate, + ctrl.hide, +) + +teamsRouter.post( + '/:id/unhide', + // #swagger.tags = ['Admin · Teams'] + // #swagger.summary = 'Un-hide a Team — admin applies, moderator requests' + // #swagger.description = 'One of the three gated actions: it publishes a name that tripped the impersonation list. An admin applies it at once; a moderator files a pending request and nothing changes publicly until an admin approves.' + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' } + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: false, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamReasonRequest" } } } } */ + /* #swagger.responses[200] = { description: 'Applied, or filed for approval — see `pending`', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamModerationResult" } } } } */ + /* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt({ min: 1 }).toInt(), + body('reason').optional().isString().trim().isLength({ max: 255 }), + validate, + ctrl.unhide, +) + +teamsRouter.post( + '/:id/display-name', + // #swagger.tags = ['Admin · Teams'] + // #swagger.summary = 'Set or clear a Team’s display name — admin applies, moderator requests' + // #swagger.description = 'Gated for the same reason as un-hiding: it substitutes free text into the same public surfaces. Identity is untouched — the Team’s `name` stays frozen for the life of the row, and only what is rendered changes. An empty displayName clears the override.' + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' } + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamDisplayNameRequest" } } } } */ + /* #swagger.responses[200] = { description: 'Applied, or filed for approval — see `pending`', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamModerationResult" } } } } */ + /* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */ + /* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt({ min: 1 }).toInt(), + body('displayName').optional({ nullable: true }).isString().trim().isLength({ max: 160 }), + body('reason').optional().isString().trim().isLength({ max: 255 }), + validate, + ctrl.displayName, +) + +teamsRouter.post( + '/:id/leader-override', + // #swagger.tags = ['Admin · Teams'] + // #swagger.summary = 'Grant or deny leadership for one member (staff)' + // #swagger.description = 'Applied on top of the synced value at READ time; the projection is never mutated. That is what makes an override survive a resync — one written into team_members would be undone by the next reconciliation. Not gated: it publishes no game-sourced string.' + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' } + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TeamLeaderOverrideRequest" } } } } */ + /* #swagger.responses[200] = { description: 'Override set', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */ + /* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */ + /* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt({ min: 1 }).toInt(), + body('memberKey').isString().trim().isLength({ min: 1, max: 191 }), + body('effect').isIn(['grant', 'deny']), + body('reason').optional().isString().trim().isLength({ max: 255 }), + validate, + ctrl.setLeaderOverride, +) + +teamsRouter.delete( + '/:id/leader-override/:memberKey', + // #swagger.tags = ['Admin · Teams'] + // #swagger.summary = 'Clear a leadership override (staff)' + // #swagger.description = 'The member reverts to whatever the game says at the next read; nothing in the projection changes, because nothing in it was ever changed.' + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Team id.' } + // #swagger.parameters['memberKey'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The module’s member key.' } + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Override cleared', content: { "application/json": { schema: { $ref: "#/components/schemas/OkResponse" } } } } */ + /* #swagger.responses[404] = { description: 'No such override', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt({ min: 1 }).toInt(), + param('memberKey').isString().trim().isLength({ min: 1, max: 191 }), + validate, + ctrl.clearLeaderOverride, +) + +module.exports = teamsRouter diff --git a/server/src/router/v1/player/index.js b/server/src/router/v1/player/index.js index c02a1ca..ffe8298 100644 --- a/server/src/router/v1/player/index.js +++ b/server/src/router/v1/player/index.js @@ -26,6 +26,7 @@ const noindex = require('../../../middleware/noindex') const accountRouter = require('./account.router') const appealsRouter = require('./appeals.router') +const teamsRouter = require('./teams.router') const playerRouter = express.Router() @@ -39,5 +40,6 @@ playerRouter.use(noindex, requireAuth) playerRouter.use('/account', accountRouter) playerRouter.use('/appeals', appealsRouter) +playerRouter.use('/teams', teamsRouter) module.exports = playerRouter diff --git a/server/src/router/v1/player/teams.controller.js b/server/src/router/v1/player/teams.controller.js new file mode 100644 index 0000000..a37447f --- /dev/null +++ b/server/src/router/v1/player/teams.controller.js @@ -0,0 +1,28 @@ +// Player · Teams — self-scoped reads. Neither handler takes an identity from the +// caller; both use req.user.id, which the tier's requireAuth has already proved. + +const teams = require('../../../model/teams/teams.model') + +const log = require('../../../utils/logger')('teams') + +async function listMine(req, res) { + try { + return res.json(await teams.listForUser(req.user.id)) + } catch (err) { + log.error('player teams: list failed', { message: err.message }) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +async function getMyAccess(req, res) { + try { + const resolved = await teams.accessForUser(req.params.slug, req.user.id) + if (!resolved) return res.status(404).json({ message: 'Team not found' }) + return res.json(resolved) + } catch (err) { + log.error('player teams: access failed', { message: err.message }) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +module.exports = { listMine, getMyAccess } diff --git a/server/src/router/v1/player/teams.router.js b/server/src/router/v1/player/teams.router.js new file mode 100644 index 0000000..4f1bc53 --- /dev/null +++ b/server/src/router/v1/player/teams.router.js @@ -0,0 +1,46 @@ +// Player · Teams — the caller's own Teams and their own access on one. +// +// Mounted at /api/v1/player/teams by player/index.js, which already applied +// `noindex, requireAuth`. No extra gate: both handlers are self-scoped to +// req.user.id and neither takes a user id from the caller. +// +// **Staff are a superset of players.** This group is open to any authenticated +// account, not just role 'player' — a moderator is in guilds too, and gating on +// the role would 403 them off their own Teams. That mistake has been made here +// once already (see player/index.js). +// +// Leader-exercised actions — granting forum access — land in phase 4 and will +// live under this same prefix rather than under /admin: a leader is a player, and +// the /admin tier gate is requireRole('admin','editor','moderator'), so putting a +// leader endpoint behind it would mean widening that gate. + +const express = require('express') + +const ctrl = require('./teams.controller') + +const teamsRouter = express.Router() + +teamsRouter.get( + '/', + // #swagger.tags = ['Player · Teams'] + // #swagger.summary = 'List the caller’s Teams, with the reason for each' + // #swagger.description = 'Membership and forum grants are separate authority paths, so each Team carries `reason`: membership | grant | both. A Team hidden from public surfaces is still listed here — suppression is a public-surface rule, and a member is not a member of the public.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'The caller’s Teams', content: { "application/json": { schema: { $ref: "#/components/schemas/PlayerTeamList" } } } } */ + /* #swagger.responses[403] = { description: 'Account not active (disabled/banned)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + ctrl.listMine, +) + +teamsRouter.get( + '/:slug/access', + // #swagger.tags = ['Player · Teams'] + // #swagger.summary = 'The caller’s own resolved access on one Team' + // #swagger.description = 'Reports viaMembership and viaGrant separately, and keeps both when both hold: the UI presents membership as the current reason while the grant survives as audit history.' + // #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' } + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'The caller’s access', content: { "application/json": { schema: { $ref: "#/components/schemas/PlayerTeamAccess" } } } } */ + /* #swagger.responses[404] = { description: 'No such Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + ctrl.getMyAccess, +) + +module.exports = teamsRouter diff --git a/server/src/router/v1/public/index.js b/server/src/router/v1/public/index.js index 0a1139b..fb7f636 100644 --- a/server/src/router/v1/public/index.js +++ b/server/src/router/v1/public/index.js @@ -21,6 +21,7 @@ const postsRouter = require('./posts.router') const wikiRouter = require('./wiki.router') const pagesRouter = require('./pages.router') const modulesRouter = require('./modules.router') +const teamsRouter = require('./teams.router') const siteRouter = require('./site.router') const publicRouter = express.Router() @@ -36,6 +37,10 @@ publicRouter.use('/pages', pagesRouter) // /modules unclaimable by a module. Never site-mode gated: a client must be able // to feature-detect while the site is in maintenance. publicRouter.use('/modules', modulesRouter) +// Teams. A core prefix, not a module's: the entity is core's even though a module +// is what populates it (TEAMS.md §10.3). Site-mode gated per route, like the +// content above it. +publicRouter.use('/teams', teamsRouter) // The four singletons that own no path segment of their own: /settings, /status, // /version and /contact. Mounted at the group root, last — safe only because diff --git a/server/src/router/v1/public/teams.controller.js b/server/src/router/v1/public/teams.controller.js new file mode 100644 index 0000000..701cc9b --- /dev/null +++ b/server/src/router/v1/public/teams.controller.js @@ -0,0 +1,48 @@ +// Public · Teams — the anonymous read surface (TEAMS.md §2.11). +// +// Every handler here is a projection over core's own tables; nothing calls the +// module. A Team page must render while the shard is down, showing a roster +// marked stale, because that is what the projection is for. + +const teams = require('../../../model/teams/teams.model') + +const log = require('../../../utils/logger')('teams') + +const fail = (res, err, what) => { + log.error(`public teams: ${what} failed`, { message: err.message }) + return res.status(500).json({ message: 'Internal Server Error' }) +} + +async function listTeams(req, res) { + try { + const limit = Math.min(Number.parseInt(req.query.limit, 10) || 50, 200) + const offset = Math.max(Number.parseInt(req.query.offset, 10) || 0, 0) + return res.json(await teams.listPublic({ limit, offset })) + } catch (err) { + return fail(res, err, 'list') + } +} + +async function getTeam(req, res) { + try { + const team = await teams.getPublic(req.params.slug) + // A hidden Team is indistinguishable from a missing one here, deliberately: + // "absent from every public surface" includes not confirming it exists. + if (!team) return res.status(404).json({ message: 'Team not found' }) + return res.json(team) + } catch (err) { + return fail(res, err, 'get') + } +} + +async function getRoster(req, res) { + try { + const roster = await teams.rosterPublic(req.params.slug) + if (!roster) return res.status(404).json({ message: 'Team not found' }) + return res.json(roster) + } catch (err) { + return fail(res, err, 'roster') + } +} + +module.exports = { listTeams, getTeam, getRoster } diff --git a/server/src/router/v1/public/teams.router.js b/server/src/router/v1/public/teams.router.js new file mode 100644 index 0000000..3b17d25 --- /dev/null +++ b/server/src/router/v1/public/teams.router.js @@ -0,0 +1,54 @@ +// Public · Teams — the anonymous Team surface (TEAMS.md §2.11). +// +// Mounted at /api/v1/public/teams by public/index.js. No group gate: this is the +// anonymous surface, and `siteMode` is applied per route as everywhere else in +// this tier — during maintenance only an admin with a valid session sees content. +// +// Declaration order: '/' is literal and precedes the two :slug routes, and +// '/:slug/members' is deeper than '/:slug', so nothing here can shadow anything +// else. + +const express = require('express') + +const ctrl = require('./teams.controller') +const siteMode = require('../../../middleware/siteMode') + +const teamsRouter = express.Router() + +teamsRouter.get( + '/', + // #swagger.tags = ['Public · Teams'] + // #swagger.summary = 'List active, publicly visible Teams' + // #swagger.description = 'Teams hidden by reserved-name screening or by staff are absent. The response carries { stale, lastSyncAt } so a client can say how recently the projection was confirmed against the game.' + // #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Page size, max 200 (default 50).' } + // #swagger.parameters['offset'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Rows to skip (default 0).' } + /* #swagger.responses[200] = { description: 'Publicly visible Teams, with sync freshness', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicTeamList" } } } } */ + siteMode, + ctrl.listTeams, +) + +teamsRouter.get( + '/:slug', + // #swagger.tags = ['Public · Teams'] + // #swagger.summary = 'Get one Team by slug' + // #swagger.description = 'An archived Team still resolves, read-only, and names its successor when it was renamed — an old bookmark or Discord link lands somewhere that explains itself. A hidden Team returns 404, indistinguishable from one that does not exist.' + // #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' } + /* #swagger.responses[200] = { description: 'The Team', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicTeam" } } } } */ + /* #swagger.responses[404] = { description: 'No such Team, or it is hidden', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + siteMode, + ctrl.getTeam, +) + +teamsRouter.get( + '/:slug/members', + // #swagger.tags = ['Public · Teams'] + // #swagger.summary = 'Get a Team roster' + // #swagger.description = 'In-game display names only. A member key is a game-internal identifier and a user id names a site account; neither is published. `linked` answers whether a character has an account behind it without saying which.' + // #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' } + /* #swagger.responses[200] = { description: 'The roster, with sync freshness', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicTeamRoster" } } } } */ + /* #swagger.responses[404] = { description: 'No such Team, or it is hidden', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + siteMode, + ctrl.getRoster, +) + +module.exports = teamsRouter diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index 0a5a789..60a8b1b 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -4309,6 +4309,727 @@ } } }, + "/api/v1/admin/teams": { + "get": { + "tags": [ + "Admin · Teams" + ], + "summary": "List Teams with sync state", + "description": "Includes hidden Teams and the module’s sync state verbatim — last attempt, last success, consecutive failures and the last error — which is what an operator debugging a stale projection needs.", + "parameters": [ + { + "name": "archived", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Set to 1 to include archived Teams." + } + ], + "responses": { + "200": { + "description": "Teams and sync state", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminTeamList" + } + } + } + }, + "400": { + "description": "Bad Request" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/teams/requests": { + "get": { + "tags": [ + "Admin · Teams" + ], + "summary": "The moderation approval queue", + "description": "Requests filed by moderators for the three actions that publish untrusted game-sourced strings. Decided rows are kept — the record that a moderator asked to publish a name and an admin refused is the part worth having.", + "parameters": [ + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "pending (default) | approved | rejected | withdrawn | all" + } + ], + "responses": { + "200": { + "description": "Moderation requests", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamRequestQueue" + } + } + } + }, + "400": { + "description": "Bad Request" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/teams/requests/{id}/decide": { + "post": { + "tags": [ + "Admin · Teams" + ], + "summary": "Approve or reject a moderation request (admin only)", + "description": "Admin only, checked live against the database rather than from a token claim. Approving applies the action; rejecting keeps the row and changes nothing. A request already decided returns 409, so two admins deciding at once cannot double-apply.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Request id." + } + ], + "responses": { + "200": { + "description": "Decided", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OkResponse" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + } + }, + "403": { + "description": "Only an admin may decide a request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "No such request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Already decided", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamDecideRequest" + } + } + } + } + } + }, + "/api/v1/admin/teams/resync": { + "post": { + "tags": [ + "Admin · Teams" + ], + "summary": "Run a reconciliation now", + "description": "Awaited, so the response carries the outcome including the provider’s own error when it refused. The four refusal gates still apply — a manual resync cannot make core act on an answer it does not trust.", + "responses": { + "200": { + "description": "The reconciliation result", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamResyncResult" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/teams/review": { + "get": { + "tags": [ + "Admin · Teams" + ], + "summary": "The reserved-name review queue", + "description": "Teams auto-hidden because their name matched a reserved term, each showing which term matched. A Team a human has already ruled on leaves the queue and is never re-hidden by a later sweep.", + "responses": { + "200": { + "description": "Auto-hidden Teams awaiting review", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamReviewQueue" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/teams/{id}": { + "get": { + "tags": [ + "Admin · Teams" + ], + "summary": "Get one Team, with its roster, grant ledger and pending requests", + "description": "The roster carries the resolved leadership and what the game actually said, so an override is visible as a decision rather than presented as fact. Departed members are included.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Team id." + } + ], + "responses": { + "200": { + "description": "The Team", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminTeam" + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "No such Team", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/teams/{id}/archive": { + "post": { + "tags": [ + "Admin · Teams" + ], + "summary": "Archive a Team (staff)", + "description": "Not gated: archiving withdraws a Team from public surfaces rather than publishing anything.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Team id." + } + ], + "responses": { + "200": { + "description": "Archived", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OkResponse" + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "No such Team", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamReasonRequest" + } + } + } + } + } + }, + "/api/v1/admin/teams/{id}/display-name": { + "post": { + "tags": [ + "Admin · Teams" + ], + "summary": "Set or clear a Team’s display name — admin applies, moderator requests", + "description": "Gated for the same reason as un-hiding: it substitutes free text into the same public surfaces. Identity is untouched — the Team’s `name` stays frozen for the life of the row, and only what is rendered changes. An empty displayName clears the override.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Team id." + } + ], + "responses": { + "200": { + "description": "Applied, or filed for approval — see `pending`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamModerationResult" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + } + }, + "404": { + "description": "No such Team", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamDisplayNameRequest" + } + } + } + } + } + }, + "/api/v1/admin/teams/{id}/grants": { + "get": { + "tags": [ + "Admin · Teams" + ], + "summary": "The full forum-grant ledger for a Team, revoked rows included", + "description": "The structured record the access resolver reads. The grant/revoke flow itself lands in the forum phase; this is the read side.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Team id." + } + ], + "responses": { + "200": { + "description": "The grant ledger", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamGrantLedger" + } + } + } + }, + "400": { + "description": "Bad Request" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/teams/{id}/hide": { + "post": { + "tags": [ + "Admin · Teams" + ], + "summary": "Hide a Team from public surfaces (staff)", + "description": "Deliberately NOT gated. Publishing untrusted data needs a second pair of eyes; withdrawing it needs to be possible at once, by whoever is on duty.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Team id." + } + ], + "responses": { + "200": { + "description": "Hidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OkResponse" + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "No such Team", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamReasonRequest" + } + } + } + } + } + }, + "/api/v1/admin/teams/{id}/leader-override": { + "post": { + "tags": [ + "Admin · Teams" + ], + "summary": "Grant or deny leadership for one member (staff)", + "description": "Applied on top of the synced value at READ time; the projection is never mutated. That is what makes an override survive a resync — one written into team_members would be undone by the next reconciliation. Not gated: it publishes no game-sourced string.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Team id." + } + ], + "responses": { + "200": { + "description": "Override set", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OkResponse" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + } + }, + "404": { + "description": "No such Team", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamLeaderOverrideRequest" + } + } + } + } + } + }, + "/api/v1/admin/teams/{id}/leader-override/{memberKey}": { + "delete": { + "tags": [ + "Admin · Teams" + ], + "summary": "Clear a leadership override (staff)", + "description": "The member reverts to whatever the game says at the next read; nothing in the projection changes, because nothing in it was ever changed.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Team id." + }, + { + "name": "memberKey", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The module’s member key." + } + ], + "responses": { + "200": { + "description": "Override cleared", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OkResponse" + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "No such override", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/teams/{id}/unhide": { + "post": { + "tags": [ + "Admin · Teams" + ], + "summary": "Un-hide a Team — admin applies, moderator requests", + "description": "One of the three gated actions: it publishes a name that tripped the impersonation list. An admin applies it at once; a moderator files a pending request and nothing changes publicly until an admin approves.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Team id." + } + ], + "responses": { + "200": { + "description": "Applied, or filed for approval — see `pending`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamModerationResult" + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "No such Team", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamReasonRequest" + } + } + } + } + } + }, "/api/v1/admin/uploads": { "post": { "tags": [ @@ -9268,6 +9989,110 @@ ] } }, + "/api/v1/player/teams": { + "get": { + "tags": [ + "Player · Teams" + ], + "summary": "List the caller’s Teams, with the reason for each", + "description": "Membership and forum grants are separate authority paths, so each Team carries `reason`: membership | grant | both. A Team hidden from public surfaces is still listed here — suppression is a public-surface rule, and a member is not a member of the public.", + "responses": { + "200": { + "description": "The caller’s Teams", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlayerTeamList" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Account not active (disabled/banned)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/player/teams/{slug}/access": { + "get": { + "tags": [ + "Player · Teams" + ], + "summary": "The caller’s own resolved access on one Team", + "description": "Reports viaMembership and viaGrant separately, and keeps both when both hold: the UI presents membership as the current reason while the grant survives as audit history.", + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The Team slug." + } + ], + "responses": { + "200": { + "description": "The caller’s access", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlayerTeamAccess" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "No such Team", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, "/api/v1/public/contact": { "post": { "tags": [ @@ -9613,6 +10438,140 @@ } } }, + "/api/v1/public/teams": { + "get": { + "tags": [ + "Public · Teams" + ], + "summary": "List active, publicly visible Teams", + "description": "Teams hidden by reserved-name screening or by staff are absent. The response carries { stale, lastSyncAt } so a client can say how recently the projection was confirmed against the game.", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Page size, max 200 (default 50)." + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Rows to skip (default 0)." + } + ], + "responses": { + "200": { + "description": "Publicly visible Teams, with sync freshness", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicTeamList" + } + } + } + }, + "503": { + "description": "Service Unavailable" + } + } + } + }, + "/api/v1/public/teams/{slug}": { + "get": { + "tags": [ + "Public · Teams" + ], + "summary": "Get one Team by slug", + "description": "An archived Team still resolves, read-only, and names its successor when it was renamed — an old bookmark or Discord link lands somewhere that explains itself. A hidden Team returns 404, indistinguishable from one that does not exist.", + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The Team slug." + } + ], + "responses": { + "200": { + "description": "The Team", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicTeam" + } + } + } + }, + "404": { + "description": "No such Team, or it is hidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Service Unavailable" + } + } + } + }, + "/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.", + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The Team slug." + } + ], + "responses": { + "200": { + "description": "The roster, with sync freshness", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicTeamRoster" + } + } + } + }, + "404": { + "description": "No such Team, or it is hidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Service Unavailable" + } + } + } + }, "/api/v1/public/version": { "get": { "tags": [ @@ -15728,6 +16687,2400 @@ } } } + }, + "OkResponse": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "ok": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + } + } + } + } + }, + "TeamSyncFreshness": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "Freshness of core's projection of the game's Teams. Carried on every public Team payload so a page can say how recently the roster was confirmed rather than presenting stale data as current. `configured` is false when no module supplies a Team provider — a deployment with no game module is not a stale one." + }, + "properties": { + "type": "object", + "properties": { + "configured": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "stale": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "description": { + "type": "string", + "example": "True past twice the reconcile interval, or when the projection has never synced at all." + }, + "example": { + "type": "boolean", + "example": false + } + } + }, + "lastSyncAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "consecutiveFailures": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 0 + } + } + } + } + } + } + }, + "PublicTeam": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "A Team as an anonymous caller sees it. `name` is what is DISPLAYED — a staff display-name override, when one is set — never the frozen identity behind it." + }, + "properties": { + "type": "object", + "properties": { + "slug": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "the-silver-hand" + } + } + }, + "name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "The Silver Hand" + } + } + }, + "abbr": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "TSH" + } + } + }, + "memberCount": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 42 + } + } + }, + "linkedCount": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "description": { + "type": "string", + "example": "Members with a linked site account." + }, + "example": { + "type": "number", + "example": 11 + } + } + }, + "onlineCount": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 3 + } + } + }, + "meta": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "additionalProperties": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "Module-supplied and opaque to core." + } + } + }, + "status": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "active", + "archived" + ], + "items": { + "type": "string" + } + } + } + }, + "createdAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + } + } + }, + "rosterSyncedAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "archivedAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "archivedReason": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "renamed" + } + } + }, + "successor": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "Where an archived Team continued after a rename, so an old link explains itself instead of 404ing." + }, + "properties": { + "type": "object", + "properties": { + "slug": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + }, + "name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + } + } + } + } + } + } + } + } + }, + "PublicTeamList": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "allOf": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TeamSyncFreshness" + } + }, + "properties": { + "type": "object", + "properties": { + "teams": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "$ref": "#/components/schemas/PublicTeam" + } + } + }, + "total": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 12 + } + } + } + } + } + } + }, + "PublicTeamMember": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "A roster row as an anonymous caller sees it. The member key is a game-internal identifier and the user id names a site account; neither is published. `linked` answers whether a character has an account behind it without saying which." + }, + "properties": { + "type": "object", + "properties": { + "displayName": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "Aldric" + } + } + }, + "rankLabel": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "Module vocabulary, opaque to core." + }, + "example": { + "type": "string", + "example": "Warlord" + } + } + }, + "isLeader": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "online": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": false + } + } + }, + "linked": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + } + } + } + } + }, + "PublicTeamRoster": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "allOf": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TeamSyncFreshness" + } + }, + "properties": { + "type": "object", + "properties": { + "members": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "$ref": "#/components/schemas/PublicTeamMember" + } + } + }, + "rosterSyncedAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + } + } + } + } + }, + "PlayerTeamList": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "allOf": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TeamSyncFreshness" + } + }, + "properties": { + "type": "object", + "properties": { + "teams": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "allOf": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PublicTeam" + } + }, + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "reason": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "membership", + "grant", + "both" + ], + "items": { + "type": "string" + } + }, + "description": { + "type": "string", + "example": "Which authority path lists this Team for the caller. `both` is a real state and is kept: membership is the current reason while the grant survives as audit history." + } + } + }, + "isLeader": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + } + } + } + } + } + } + } + } + } + } + } + } + }, + "PlayerTeamAccess": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "slug": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + }, + "allowed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + } + } + }, + "viaMembership": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + } + } + }, + "viaGrant": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "description": { + "type": "string", + "example": "Reported even when membership also holds." + } + } + }, + "isLeader": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "description": { + "type": "string", + "example": "The synced value with any staff override applied." + } + } + } + } + } + } + }, + "AdminTeam": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "The full staff view, including what a staff decision overrode." + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "moduleId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "uo" + } + } + }, + "externalId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "description": { + "type": "string", + "example": "The module's own stable id, opaque to core." + } + } + }, + "slug": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + }, + "name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "description": { + "type": "string", + "example": "The frozen identity. Immutable for the life of the row." + } + } + }, + "displayName": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "description": { + "type": "string", + "example": "What is rendered — the override when set, otherwise `name`." + } + } + }, + "displayNameOverride": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "abbr": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "status": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "active", + "archived" + ], + "items": { + "type": "string" + } + } + } + }, + "hidden": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + } + } + }, + "hiddenReason": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "enum": { + "type": "array", + "example": [ + "reserved_name", + "staff", + null + ], + "items": {} + } + } + }, + "hiddenTerm": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "Which reserved term matched." + }, + "example": { + "type": "string", + "example": "admin" + } + } + }, + "nameReviewedAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "Set once a human has ruled on the name; a later sweep never re-hides it." + } + } + }, + "memberCount": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "linkedCount": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "onlineCount": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "rosterSyncedAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "membersEmptySince": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "The per-Team empty-roster quarantine." + } + } + }, + "succeededBy": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "createdAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + } + } + }, + "archivedAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "archivedReason": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "meta": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "additionalProperties": { + "type": "boolean", + "example": true + } + } + }, + "members": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "$ref": "#/components/schemas/AdminTeamMember" + } + } + }, + "grants": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "$ref": "#/components/schemas/TeamGrant" + } + } + }, + "pendingRequests": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "$ref": "#/components/schemas/TeamModerationRequest" + } + } + } + } + } + } + }, + "AdminTeamMember": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "memberKey": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "0x40012ab3" + } + } + }, + "displayName": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "userId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "Resolved by the module; null means unlinked." + } + } + }, + "rankLabel": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "isLeader": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "description": { + "type": "string", + "example": "The resolved answer — synced value with any override applied." + } + } + }, + "isLeaderSynced": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "description": { + "type": "string", + "example": "What the game actually said, so an override reads as a decision rather than as fact." + } + } + }, + "leaderOverride": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "properties": { + "type": "object", + "properties": { + "effect": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "grant", + "deny" + ], + "items": { + "type": "string" + } + } + } + }, + "reason": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "by": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "at": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + } + } + } + } + } + } + }, + "online": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + } + } + }, + "status": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "active", + "departed" + ], + "items": { + "type": "string" + } + } + } + }, + "firstSeenAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + } + } + }, + "lastSeenAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + } + } + }, + "departedAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + } + } + } + } + }, + "AdminTeamList": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "allOf": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TeamSyncFreshness" + } + }, + "properties": { + "type": "object", + "properties": { + "teams": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "$ref": "#/components/schemas/AdminTeam" + } + } + }, + "syncState": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "The module's sync row verbatim, including the last error — what an operator debugging a stale projection needs." + }, + "properties": { + "type": "object", + "properties": { + "moduleId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + }, + "lastAttemptAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "lastSuccessAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "consecutiveFailures": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "lastError": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "pendingEmptySince": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + } + } + } + } + } + } + } + } + }, + "TeamGrant": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "One row of the append-only forum grant/revoke ledger. The username snapshots keep the record readable after an account is deleted — the ids go SET NULL, the audit trail does not." + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "team_id": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "user_id": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "username": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "granted_by": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "granted_username": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "granted_at": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + } + } + }, + "reason": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "revoked_by": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "revoked_username": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "revoked_at": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "revoke_reason": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + } + } + } + } + }, + "TeamGrantLedger": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "grants": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "$ref": "#/components/schemas/TeamGrant" + } + } + } + } + } + } + }, + "TeamModerationRequest": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "team_id": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "team_name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + }, + "team_slug": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + }, + "action": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "unhide", + "display_name_override", + "clear_display_name_override" + ], + "items": { + "type": "string" + } + } + } + }, + "payload": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "additionalProperties": { + "type": "boolean", + "example": true + } + } + }, + "reason": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "requested_by": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "requested_username": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "requested_at": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + } + } + }, + "status": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "pending", + "approved", + "rejected", + "withdrawn" + ], + "items": { + "type": "string" + } + } + } + }, + "decided_by": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "decided_username": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "decided_at": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "decision_note": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + } + } + } + } + }, + "TeamRequestQueue": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "requests": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "$ref": "#/components/schemas/TeamModerationRequest" + } + } + } + } + } + } + }, + "TeamReviewQueue": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "Teams auto-hidden by reserved-name screening and not yet ruled on by a human." + }, + "properties": { + "type": "object", + "properties": { + "teams": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "Admin" + } + } + }, + "slug": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + }, + "hidden_term": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "admin" + } + } + }, + "display_name_override": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "member_count": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "created_at": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + } + } + } + } + } + } + } + } + } + } + } + } + }, + "TeamModerationResult": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "The outcome of a gated action. `pending: true` means a moderator filed a request and nothing changed publicly; an admin's call applies at once and reports false." + }, + "properties": { + "type": "object", + "properties": { + "ok": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + } + } + }, + "pending": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": false + } + } + }, + "requestId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + } + } + } + } + }, + "TeamResyncResult": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "A reconciliation outcome. `ok: false` carries the provider's own reason and means nothing was written. `quarantined` means an authoritative-but-empty answer was held back for confirmation rather than applied." + }, + "properties": { + "type": "object", + "properties": { + "ok": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + } + } + }, + "reason": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "quarantined": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "created": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "renamed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "archived": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "rosters": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "Rosters actually applied; a refused one is left untouched and not counted." + } + } + }, + "rehidden": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + } + } + } + } + }, + "TeamReasonRequest": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "reason": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "maxLength": { + "type": "number", + "example": 255 + }, + "example": { + "type": "string", + "example": "impersonates staff" + } + } + } + } + } + } + }, + "TeamDisplayNameRequest": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "displayName": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "maxLength": { + "type": "number", + "example": 160 + }, + "description": { + "type": "string", + "example": "Empty or null clears the override." + }, + "example": { + "type": "string", + "example": "The Old Guard" + } + } + }, + "reason": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "maxLength": { + "type": "number", + "example": 255 + } + } + } + } + } + } + }, + "TeamLeaderOverrideRequest": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "required": { + "type": "array", + "example": [ + "memberKey", + "effect" + ], + "items": { + "type": "string" + } + }, + "properties": { + "type": "object", + "properties": { + "memberKey": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "maxLength": { + "type": "number", + "example": 191 + }, + "example": { + "type": "string", + "example": "0x40012ab3" + } + } + }, + "effect": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "grant", + "deny" + ], + "items": { + "type": "string" + } + } + } + }, + "reason": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "maxLength": { + "type": "number", + "example": 255 + } + } + } + } + } + } + }, + "TeamDecideRequest": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "required": { + "type": "array", + "example": [ + "status" + ], + "items": { + "type": "string" + } + }, + "properties": { + "type": "object", + "properties": { + "status": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "approved", + "rejected" + ], + "items": { + "type": "string" + } + } + } + }, + "note": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "maxLength": { + "type": "number", + "example": 255 + } + } + } + } + } + } } } } diff --git a/server/swagger/swagger.js b/server/swagger/swagger.js index 91197a9..03ebf40 100644 --- a/server/swagger/swagger.js +++ b/server/swagger/swagger.js @@ -927,6 +927,310 @@ const doc = { removed: { type: 'boolean', description: 'Whether the IP had an entry that was cleared.', example: true }, }, }, + + // ── Teams (docs/website/TEAMS.md) ──────────────────────────────────── + OkResponse: { + type: 'object', + properties: { ok: { type: 'boolean', example: true } }, + }, + TeamSyncFreshness: { + type: 'object', + description: + 'Freshness of core\'s projection of the game\'s Teams. Carried on every public Team payload so a page can say how recently the roster was confirmed rather than presenting stale data as current. `configured` is false when no module supplies a Team provider — a deployment with no game module is not a stale one.', + properties: { + configured: { type: 'boolean', example: true }, + stale: { + type: 'boolean', + description: 'True past twice the reconcile interval, or when the projection has never synced at all.', + example: false, + }, + lastSyncAt: { type: 'string', format: 'date-time', nullable: true }, + consecutiveFailures: { type: 'integer', example: 0 }, + }, + }, + PublicTeam: { + type: 'object', + description: + 'A Team as an anonymous caller sees it. `name` is what is DISPLAYED — a staff display-name override, when one is set — never the frozen identity behind it.', + properties: { + slug: { type: 'string', example: 'the-silver-hand' }, + name: { type: 'string', example: 'The Silver Hand' }, + abbr: { type: 'string', nullable: true, example: 'TSH' }, + memberCount: { type: 'integer', example: 42 }, + linkedCount: { type: 'integer', description: 'Members with a linked site account.', example: 11 }, + onlineCount: { type: 'integer', example: 3 }, + meta: { type: 'object', nullable: true, additionalProperties: true, description: 'Module-supplied and opaque to core.' }, + status: { type: 'string', enum: ['active', 'archived'] }, + createdAt: { type: 'string', format: 'date-time' }, + rosterSyncedAt: { type: 'string', format: 'date-time', nullable: true }, + archivedAt: { type: 'string', format: 'date-time', nullable: true }, + archivedReason: { type: 'string', nullable: true, example: 'renamed' }, + successor: { + type: 'object', + nullable: true, + description: 'Where an archived Team continued after a rename, so an old link explains itself instead of 404ing.', + properties: { slug: { type: 'string' }, name: { type: 'string' } }, + }, + }, + }, + PublicTeamList: { + type: 'object', + allOf: [{ $ref: '#/components/schemas/TeamSyncFreshness' }], + properties: { + teams: { type: 'array', items: { $ref: '#/components/schemas/PublicTeam' } }, + total: { type: 'integer', example: 12 }, + }, + }, + PublicTeamMember: { + type: 'object', + description: + 'A roster row as an anonymous caller sees it. The member key is a game-internal identifier and the user id names a site account; neither is published. `linked` answers whether a character has an account behind it without saying which.', + properties: { + displayName: { type: 'string', nullable: true, example: 'Aldric' }, + rankLabel: { type: 'string', nullable: true, description: 'Module vocabulary, opaque to core.', example: 'Warlord' }, + isLeader: { type: 'boolean', example: true }, + online: { type: 'boolean', example: false }, + linked: { type: 'boolean', example: true }, + }, + }, + PublicTeamRoster: { + type: 'object', + allOf: [{ $ref: '#/components/schemas/TeamSyncFreshness' }], + properties: { + members: { type: 'array', items: { $ref: '#/components/schemas/PublicTeamMember' } }, + rosterSyncedAt: { type: 'string', format: 'date-time', nullable: true }, + }, + }, + PlayerTeamList: { + type: 'object', + allOf: [{ $ref: '#/components/schemas/TeamSyncFreshness' }], + properties: { + teams: { + type: 'array', + items: { + allOf: [{ $ref: '#/components/schemas/PublicTeam' }], + type: 'object', + properties: { + reason: { + type: 'string', + enum: ['membership', 'grant', 'both'], + description: 'Which authority path lists this Team for the caller. `both` is a real state and is kept: membership is the current reason while the grant survives as audit history.', + }, + isLeader: { type: 'boolean' }, + }, + }, + }, + }, + }, + PlayerTeamAccess: { + type: 'object', + properties: { + slug: { type: 'string' }, + allowed: { type: 'boolean' }, + viaMembership: { type: 'boolean' }, + viaGrant: { type: 'boolean', description: 'Reported even when membership also holds.' }, + isLeader: { type: 'boolean', description: 'The synced value with any staff override applied.' }, + }, + }, + AdminTeam: { + type: 'object', + description: 'The full staff view, including what a staff decision overrode.', + properties: { + id: { type: 'integer' }, + moduleId: { type: 'string', example: 'uo' }, + externalId: { type: 'string', description: 'The module\'s own stable id, opaque to core.' }, + slug: { type: 'string' }, + name: { type: 'string', description: 'The frozen identity. Immutable for the life of the row.' }, + displayName: { type: 'string', description: 'What is rendered — the override when set, otherwise `name`.' }, + displayNameOverride: { type: 'string', nullable: true }, + abbr: { type: 'string', nullable: true }, + status: { type: 'string', enum: ['active', 'archived'] }, + hidden: { type: 'boolean' }, + hiddenReason: { type: 'string', nullable: true, enum: ['reserved_name', 'staff', null] }, + hiddenTerm: { type: 'string', nullable: true, description: 'Which reserved term matched.', example: 'admin' }, + nameReviewedAt: { type: 'string', format: 'date-time', nullable: true, description: 'Set once a human has ruled on the name; a later sweep never re-hides it.' }, + memberCount: { type: 'integer' }, + linkedCount: { type: 'integer' }, + onlineCount: { type: 'integer' }, + rosterSyncedAt: { type: 'string', format: 'date-time', nullable: true }, + membersEmptySince: { type: 'string', format: 'date-time', nullable: true, description: 'The per-Team empty-roster quarantine.' }, + succeededBy: { type: 'integer', nullable: true }, + createdAt: { type: 'string', format: 'date-time' }, + archivedAt: { type: 'string', format: 'date-time', nullable: true }, + archivedReason: { type: 'string', nullable: true }, + meta: { type: 'object', nullable: true, additionalProperties: true }, + members: { type: 'array', items: { $ref: '#/components/schemas/AdminTeamMember' } }, + grants: { type: 'array', items: { $ref: '#/components/schemas/TeamGrant' } }, + pendingRequests: { type: 'array', items: { $ref: '#/components/schemas/TeamModerationRequest' } }, + }, + }, + AdminTeamMember: { + type: 'object', + properties: { + memberKey: { type: 'string', example: '0x40012ab3' }, + displayName: { type: 'string', nullable: true }, + userId: { type: 'integer', nullable: true, description: 'Resolved by the module; null means unlinked.' }, + rankLabel: { type: 'string', nullable: true }, + isLeader: { type: 'boolean', description: 'The resolved answer — synced value with any override applied.' }, + isLeaderSynced: { type: 'boolean', description: 'What the game actually said, so an override reads as a decision rather than as fact.' }, + leaderOverride: { + type: 'object', + nullable: true, + properties: { + effect: { type: 'string', enum: ['grant', 'deny'] }, + reason: { type: 'string', nullable: true }, + by: { type: 'string', nullable: true }, + at: { type: 'string', format: 'date-time' }, + }, + }, + online: { type: 'boolean' }, + status: { type: 'string', enum: ['active', 'departed'] }, + firstSeenAt: { type: 'string', format: 'date-time' }, + lastSeenAt: { type: 'string', format: 'date-time' }, + departedAt: { type: 'string', format: 'date-time', nullable: true }, + }, + }, + AdminTeamList: { + type: 'object', + allOf: [{ $ref: '#/components/schemas/TeamSyncFreshness' }], + properties: { + teams: { type: 'array', items: { $ref: '#/components/schemas/AdminTeam' } }, + syncState: { + type: 'object', + nullable: true, + description: 'The module\'s sync row verbatim, including the last error — what an operator debugging a stale projection needs.', + properties: { + moduleId: { type: 'string' }, + lastAttemptAt: { type: 'string', format: 'date-time', nullable: true }, + lastSuccessAt: { type: 'string', format: 'date-time', nullable: true }, + consecutiveFailures: { type: 'integer' }, + lastError: { type: 'string', nullable: true }, + pendingEmptySince: { type: 'string', format: 'date-time', nullable: true }, + }, + }, + }, + }, + TeamGrant: { + type: 'object', + description: + 'One row of the append-only forum grant/revoke ledger. The username snapshots keep the record readable after an account is deleted — the ids go SET NULL, the audit trail does not.', + properties: { + id: { type: 'integer' }, + team_id: { type: 'integer' }, + user_id: { type: 'integer', nullable: true }, + username: { type: 'string', nullable: true }, + granted_by: { type: 'integer', nullable: true }, + granted_username: { type: 'string', nullable: true }, + granted_at: { type: 'string', format: 'date-time' }, + reason: { type: 'string', nullable: true }, + revoked_by: { type: 'integer', nullable: true }, + revoked_username: { type: 'string', nullable: true }, + revoked_at: { type: 'string', format: 'date-time', nullable: true }, + revoke_reason: { type: 'string', nullable: true }, + }, + }, + TeamGrantLedger: { + type: 'object', + properties: { grants: { type: 'array', items: { $ref: '#/components/schemas/TeamGrant' } } }, + }, + TeamModerationRequest: { + type: 'object', + properties: { + id: { type: 'integer' }, + team_id: { type: 'integer' }, + team_name: { type: 'string' }, + team_slug: { type: 'string' }, + action: { type: 'string', enum: ['unhide', 'display_name_override', 'clear_display_name_override'] }, + payload: { type: 'object', nullable: true, additionalProperties: true }, + reason: { type: 'string', nullable: true }, + requested_by: { type: 'integer', nullable: true }, + requested_username: { type: 'string', nullable: true }, + requested_at: { type: 'string', format: 'date-time' }, + status: { type: 'string', enum: ['pending', 'approved', 'rejected', 'withdrawn'] }, + decided_by: { type: 'integer', nullable: true }, + decided_username: { type: 'string', nullable: true }, + decided_at: { type: 'string', format: 'date-time', nullable: true }, + decision_note: { type: 'string', nullable: true }, + }, + }, + TeamRequestQueue: { + type: 'object', + properties: { requests: { type: 'array', items: { $ref: '#/components/schemas/TeamModerationRequest' } } }, + }, + TeamReviewQueue: { + type: 'object', + description: 'Teams auto-hidden by reserved-name screening and not yet ruled on by a human.', + properties: { + teams: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'integer' }, + name: { type: 'string', example: 'Admin' }, + slug: { type: 'string' }, + hidden_term: { type: 'string', example: 'admin' }, + display_name_override: { type: 'string', nullable: true }, + member_count: { type: 'integer' }, + created_at: { type: 'string', format: 'date-time' }, + }, + }, + }, + }, + }, + TeamModerationResult: { + type: 'object', + description: + 'The outcome of a gated action. `pending: true` means a moderator filed a request and nothing changed publicly; an admin\'s call applies at once and reports false.', + properties: { + ok: { type: 'boolean' }, + pending: { type: 'boolean', example: false }, + requestId: { type: 'integer', nullable: true }, + }, + }, + TeamResyncResult: { + type: 'object', + description: + 'A reconciliation outcome. `ok: false` carries the provider\'s own reason and means nothing was written. `quarantined` means an authoritative-but-empty answer was held back for confirmation rather than applied.', + properties: { + ok: { type: 'boolean' }, + reason: { type: 'string', nullable: true }, + quarantined: { type: 'boolean', nullable: true }, + created: { type: 'integer', nullable: true }, + renamed: { type: 'integer', nullable: true }, + archived: { type: 'integer', nullable: true }, + rosters: { type: 'integer', nullable: true, description: 'Rosters actually applied; a refused one is left untouched and not counted.' }, + rehidden: { type: 'integer', nullable: true }, + }, + }, + TeamReasonRequest: { + type: 'object', + properties: { reason: { type: 'string', maxLength: 255, example: 'impersonates staff' } }, + }, + TeamDisplayNameRequest: { + type: 'object', + properties: { + displayName: { type: 'string', nullable: true, maxLength: 160, description: 'Empty or null clears the override.', example: 'The Old Guard' }, + reason: { type: 'string', maxLength: 255 }, + }, + }, + TeamLeaderOverrideRequest: { + type: 'object', + required: ['memberKey', 'effect'], + properties: { + memberKey: { type: 'string', maxLength: 191, example: '0x40012ab3' }, + effect: { type: 'string', enum: ['grant', 'deny'] }, + reason: { type: 'string', maxLength: 255 }, + }, + }, + TeamDecideRequest: { + type: 'object', + required: ['status'], + properties: { + status: { type: 'string', enum: ['approved', 'rejected'] }, + note: { type: 'string', maxLength: 255 }, + }, + }, }, }, } diff --git a/server/test/teamRoutes.test.js b/server/test/teamRoutes.test.js new file mode 100644 index 0000000..4987ed2 --- /dev/null +++ b/server/test/teamRoutes.test.js @@ -0,0 +1,304 @@ +// The Team API's access boundaries, exercised through the real routers +// (docs/website/TEAMS.md §2.11). +// +// The models are stubbed; what is under test is the wiring — which tier a route +// sits behind, what a hidden Team does to a public caller, and the one route that +// is admin-only inside a staff-wide group. Those are the properties a reviewer +// cannot check by reading a controller in isolation, because they are decided by +// the mount table and by a role read at request time. +process.env.DB_HOST = '127.0.0.1' +process.env.DB_PORT = '59999' + +const { test, after, afterEach } = require('node:test') +const assert = require('node:assert/strict') + +const { startApp } = require('./_helper') +const publicRouter = require('../src/router/v1/public') +const playerRouter = require('../src/router/v1/player') +const adminRouter = require('../src/router/v1/admin') +const sessionService = require('../src/auth/session.service') +const users = require('../src/model/users/users.model') +const teams = require('../src/model/teams/teams.model') +const teamsDbModule = require('../src/model/teams/teams.db') +const moderation = require('../src/model/teams/teamModeration.model') +const teamSync = require('../src/model/teams/teamSync.model') +const activity = require('../src/model/activity/activity.model') +const settings = require('../src/model/settings/settings.model') +const db = require('../src/utils/db') + +after(() => db.close()) + +const saved = [] +function patch(mod, name, fn) { + saved.push([mod, name, mod[name]]) + mod[name] = fn +} +afterEach(() => { + while (saved.length) { + const [mod, name, fn] = saved.pop() + mod[name] = fn + } +}) + +function signInAs(user) { + patch(sessionService, 'validateSession', () => ({ userId: user.id, sessionId: 's1', createdAt: Date.now(), authMethod: 'jwt' })) + patch(sessionService, 'isSessionRevoked', async () => false) + patch(sessionService, 'sessionMeta', () => ({})) + patch(users, 'getById', async () => user) + // Every staff action writes the audit log, and the real one inserts a row. It + // swallows its own errors, so an unstubbed call is not a failure — it is ten + // seconds of connection retries against the dead pool, per test. + patch(activity, 'log', async () => {}) +} + +// siteMode reads settings; keep the public tier out of maintenance. +const liveSite = () => patch(settings, 'get', async () => 'live') + +const admin = { id: 1, username: 'root', role: 'admin', status: 'active' } +const moderator = { id: 2, username: 'mod1', role: 'moderator', status: 'active' } +const player = { id: 3, username: 'ada', role: 'player', status: 'active' } + +async function withApp(mountPath, router, fn) { + const app = await startApp((a) => a.use(mountPath, router)) + try { + return await fn(app) + } finally { + await app.close() + } +} + +const get = (app, path, init) => fetch(`${app.url}${path}`, init) +const post = (app, path, body) => fetch(`${app.url}${path}`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body || {}), +}) + +// ── The public tier is anonymous, and hidden means absent ────────────────── + +test('the public Team routes need no session', async () => { + liveSite() + patch(teams, 'listPublic', async () => ({ teams: [{ slug: 'a' }], total: 1, stale: false, lastSyncAt: null })) + await withApp('/api/v1/public', publicRouter, async (app) => { + const res = await get(app, '/api/v1/public/teams') + assert.equal(res.status, 200) + const body = await res.json() + assert.equal(body.total, 1) + assert.equal(body.stale, false, 'freshness travels with every public payload') + }) +}) + +test('a hidden Team is a 404 to the public, indistinguishable from a missing one', async () => { + liveSite() + // The model returns null for hidden and for missing alike; the route must not + // tell them apart either, or "absent from every public surface" leaks the fact + // that the Team exists. + patch(teams, 'getPublic', async () => null) + patch(teams, 'rosterPublic', async () => null) + await withApp('/api/v1/public', publicRouter, async (app) => { + assert.equal((await get(app, '/api/v1/public/teams/admin')).status, 404) + assert.equal((await get(app, '/api/v1/public/teams/admin/members')).status, 404) + }) +}) + +test('the index and the by-slug lookup agree about what exists', async () => { + // Found live: the index was keyed on a registered provider while the lookup + // goes by slug, so with the module uninstalled `/teams` was empty while + // `/teams/:slug/members` served a full roster — the index denying a Team that + // direct URLs answered for. The rows are core's and outlive the module that + // filled them; `configured: false` is how a client learns the projection is no + // longer maintained. + const rows = [ + { id: 1, slug: 'the-silver-hand', name: 'The Silver Hand', status: 'active', hidden: 0, member_count: 2 }, + { id: 2, slug: 'admin', name: 'Admin', status: 'active', hidden: 1, member_count: 1 }, + ] + patch(teamsDbModule, 'allActive', async () => rows) + patch(teamsDbModule, 'findBySlug', async (slug) => rows.find((r) => r.slug === slug)) + patch(teamsDbModule, 'syncState', async () => null) + + await withApp('/api/v1/public', publicRouter, async (app) => { + liveSite() + const list = await (await get(app, '/api/v1/public/teams')).json() + assert.equal(list.total, 1, 'the hidden Team is absent from the index') + assert.equal(list.configured, false, 'with no provider, the projection is reported unmaintained') + assert.equal(list.teams[0].slug, 'the-silver-hand') + + // Everything the index lists resolves, and nothing it omits does. + assert.equal((await get(app, '/api/v1/public/teams/the-silver-hand')).status, 200) + assert.equal((await get(app, '/api/v1/public/teams/admin')).status, 404) + }) +}) + +test('a public roster never carries a member key or a user id', async () => { + liveSite() + patch(teams, 'rosterPublic', async () => ({ + members: [teams.publicMember({ + display_name: 'Aldric', rank_label: 'Warlord', is_leader: 1, online: 1, user_id: 7, member_key: '0x1', + })], + stale: false, + lastSyncAt: null, + })) + await withApp('/api/v1/public', publicRouter, async (app) => { + const body = await (await get(app, '/api/v1/public/teams/x/members')).json() + const [member] = body.members + assert.equal(member.displayName, 'Aldric') + assert.equal(member.linked, true) + assert.equal('userId' in member, false, 'a site account id is not public') + assert.equal('memberKey' in member, false, 'a game-internal identifier is not public') + }) +}) + +// ── The player tier is authenticated, and role-agnostic ─────────────────── + +test('the player Team routes reject an anonymous caller', async () => { + await withApp('/api/v1/player', playerRouter, async (app) => { + assert.equal((await get(app, '/api/v1/player/teams')).status, 401) + }) +}) + +test('staff are a superset of players — an admin reaches their own Teams', async () => { + // The mistake this guards against has been made in this group once already: a + // requireRole('player') here 403s an admin off their own characters. + patch(teams, 'listForUser', async (userId) => ({ teams: [], forUser: userId, stale: false })) + for (const user of [player, moderator, admin]) { + signInAs(user) + // eslint-disable-next-line no-await-in-loop + await withApp('/api/v1/player', playerRouter, async (app) => { + const res = await get(app, '/api/v1/player/teams') + assert.equal(res.status, 200, `${user.role} must reach their own Teams`) + assert.equal((await res.json()).forUser, user.id, 'the handler is self-scoped to the session') + }) + } +}) + +test('the player access route is scoped to the caller, not to a supplied id', async () => { + signInAs(player) + let seen = null + patch(teams, 'accessForUser', async (slug, userId) => { seen = { slug, userId }; return { slug, allowed: true } }) + await withApp('/api/v1/player', playerRouter, async (app) => { + await get(app, '/api/v1/player/teams/the-silver-hand/access?userId=1') + assert.deepEqual(seen, { slug: 'the-silver-hand', userId: player.id }, 'the query string is not an identity') + }) +}) + +// ── The admin tier is staff-wide, with one admin-only action ────────────── + +test('a player is refused the admin Team surface', async () => { + signInAs(player) + await withApp('/api/v1/admin', adminRouter, async (app) => { + assert.equal((await get(app, '/api/v1/admin/teams')).status, 403) + }) +}) + +test('a moderator reaches the review queue — that is who runs it', async () => { + signInAs(moderator) + patch(moderation, 'reviewQueue', async () => [{ id: 1, name: 'Admin', hidden_term: 'admin' }]) + await withApp('/api/v1/admin', adminRouter, async (app) => { + const res = await get(app, '/api/v1/admin/teams/review') + assert.equal(res.status, 200) + assert.equal((await res.json()).teams[0].hidden_term, 'admin') + }) +}) + +test('literal admin paths are not captured by /:id', async () => { + // Express is first-match-wins, and a /:id declared ahead of /review would turn + // the queue into a lookup for a Team whose id is "review" — a 400 from the + // validator, on a route that should have worked. + signInAs(admin) + patch(moderation, 'reviewQueue', async () => []) + patch(moderation, 'listRequests', async () => []) + patch(teamSync, 'reconcileNow', async () => ({ ok: true, created: 0 })) + await withApp('/api/v1/admin', adminRouter, async (app) => { + assert.equal((await get(app, '/api/v1/admin/teams/review')).status, 200) + assert.equal((await get(app, '/api/v1/admin/teams/requests')).status, 200) + assert.equal((await post(app, '/api/v1/admin/teams/resync')).status, 200) + }) +}) + +test('a non-numeric team id is rejected by the validator', async () => { + signInAs(admin) + await withApp('/api/v1/admin', adminRouter, async (app) => { + assert.equal((await get(app, '/api/v1/admin/teams/not-a-number')).status, 400) + }) +}) + +// ── The §2.9 gate, as the route sees it ─────────────────────────────────── + +test('a moderator un-hiding gets a pending result; an admin gets an applied one', async () => { + // The gate is decided from the caller's live role, so this asserts on the ACTOR + // the route handed the model — the thing that actually decides — rather than on + // two sessions swapped mid-test. + const calls = [] + patch(moderation, 'requestOrApply', async ({ actor, action }) => { + calls.push({ role: actor.role, action }) + return actor.role === 'admin' ? { ok: true, pending: false } : { ok: true, pending: true, requestId: 5 } + }) + + signInAs(moderator) + await withApp('/api/v1/admin', adminRouter, async (app) => { + const body = await (await post(app, '/api/v1/admin/teams/1/unhide', { reason: 'legit' })).json() + assert.equal(body.pending, true) + assert.equal(body.requestId, 5) + }) + assert.deepEqual(calls, [{ role: 'moderator', action: 'unhide' }]) +}) + +test('an admin un-hiding applies at once', async () => { + const calls = [] + patch(moderation, 'requestOrApply', async ({ actor, action }) => { + calls.push({ role: actor.role, action }) + return { ok: true, pending: false } + }) + + signInAs(admin) + await withApp('/api/v1/admin', adminRouter, async (app) => { + const body = await (await post(app, '/api/v1/admin/teams/1/unhide', {})).json() + assert.equal(body.pending, false) + }) + assert.deepEqual(calls, [{ role: 'admin', action: 'unhide' }]) +}) + +test('deciding a request is admin-only, inside a staff-wide group', async () => { + // The route is reachable by any staff member; the refusal comes from the model + // checking the role live, which is the design (§2.9) — a demoted moderator + // loses this the moment they are demoted, not when their token expires. + signInAs(moderator) + patch(moderation, 'decide', async ({ actor }) => (actor.role === 'admin' + ? { ok: true, applied: true } + : { ok: false, status: 403, error: 'only an admin may decide a request' })) + + await withApp('/api/v1/admin', adminRouter, async (app) => { + const res = await post(app, '/api/v1/admin/teams/requests/1/decide', { status: 'approved' }) + assert.equal(res.status, 403) + }) +}) + +test('an invalid decision status never reaches the model', async () => { + signInAs(admin) + let called = false + patch(moderation, 'decide', async () => { called = true; return { ok: true } }) + await withApp('/api/v1/admin', adminRouter, async (app) => { + assert.equal((await post(app, '/api/v1/admin/teams/requests/1/decide', { status: 'maybe' })).status, 400) + }) + assert.equal(called, false) +}) + +test('a leadership override requires both a member key and an effect', async () => { + signInAs(admin) + await withApp('/api/v1/admin', adminRouter, async (app) => { + assert.equal((await post(app, '/api/v1/admin/teams/1/leader-override', { effect: 'grant' })).status, 400) + assert.equal((await post(app, '/api/v1/admin/teams/1/leader-override', { memberKey: '0x1' })).status, 400) + assert.equal((await post(app, '/api/v1/admin/teams/1/leader-override', { memberKey: '0x1', effect: 'maybe' })).status, 400) + }) +}) + +test('an empty display name is routed to the CLEAR action, not published as blank', async () => { + signInAs(admin) + let action = null + patch(moderation, 'requestOrApply', async (args) => { action = args.action; return { ok: true, pending: false } }) + await withApp('/api/v1/admin', adminRouter, async (app) => { + await post(app, '/api/v1/admin/teams/1/display-name', { displayName: '' }) + assert.equal(action, 'clear_display_name_override', 'an audit line must not read as publishing a blank name') + + await post(app, '/api/v1/admin/teams/1/display-name', { displayName: 'The Old Guard' }) + assert.equal(action, 'display_name_override') + }) +}) -- 2.49.1