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

Sync

+ {freshness.label} + +
+

{freshness.detail}

+ + {syncState && ( +
+
Module
{syncState.moduleId}
+
Last attempt
{dateTime(syncState.lastAttemptAt) || 'never'}
+
Last success
{dateTime(syncState.lastSuccessAt) || 'never'}
+
Consecutive failures
{syncState.consecutiveFailures}
+ {syncState.lastError && ( + <> + {/* Verbatim. An operator debugging a stale projection needs what the + provider actually said, not a friendlier paraphrase of it. */} +
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 From aa332eda8262b7fc10ff42ff98f27747fc720209 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 17 Aug 2026 20:15:18 -0500 Subject: [PATCH 07/35] feat(teams): the activity feed, its two writers and its retention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TEAMS.md Part 4. `team_activity` takes items from two sources and treats them identically on the read path: core writes its own membership and rename items with source='core', and a module pushes game items through `ctx.teams.activity.push`, which stops throwing and starts working. Core writing here too is deliberate — the rendering path is exercised by core's own content from day one, so the feed is never empty on a deployment whose module pushes nothing. Three rules shape the model: - core never composes a summary. It arrives already rendered and is stored verbatim; core cannot phrase "gained 15,000 gold" for a game whose vocabulary it does not know. - visibility fails closed. An item with no stated visibility is `members`. - a push never throws at its call site. It is called from inside a game-event handler, and a storage problem of core's must not become the module's control flow. Core emits four of the five kinds §4.2 names — `core.forum.thread` has nothing to emit it until the forum lands in phase 4 — and emits none of them for a Team's FIRST roster: importing a 155-member guild is one Team arriving, not 155 people joining, and a join per member would bury every real event under the import and reach the row cap on day one. Retention ships with the feed rather than after someone notices. A nightly worker applies an age horizon and a per-Team row cap, both settings; either alone has a hole, since age lets one busy guild write a million rows inside the window and a cap keeps a dead Team's feed forever. The sync now reads member ROWS rather than keys, replacing the `memberKeys` call rather than adding to it: the feed needs each changing member's display name and prior `is_leader`, and the upsert is about to overwrite both. Co-Authored-By: Claude --- server/db/schema.sql | 40 +++ server/src/model/teams/teamActivity.db.js | 133 ++++++++ server/src/model/teams/teamActivity.model.js | 312 +++++++++++++++++++ server/src/model/teams/teamSync.model.js | 88 +++++- server/src/modules/loader.js | 21 +- server/src/server.js | 7 + server/src/utils/teamActivityPrune.js | 64 ++++ server/test/teamActivity.test.js | 271 ++++++++++++++++ server/test/teamSync.test.js | 173 ++++++++++ 9 files changed, 1100 insertions(+), 9 deletions(-) create mode 100644 server/src/model/teams/teamActivity.db.js create mode 100644 server/src/model/teams/teamActivity.model.js create mode 100644 server/src/utils/teamActivityPrune.js create mode 100644 server/test/teamActivity.test.js diff --git a/server/db/schema.sql b/server/db/schema.sql index 2808561..2fd7ec4 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -1051,6 +1051,46 @@ CREATE TABLE IF NOT EXISTS team_moderation_requests ( INDEX idx_tmr_queue (status, requested_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +-- The per-Team activity feed (TEAMS.md §4.2, phase 3). Two writers, one table: +-- core writes its own membership and rename items with source='core', and a module +-- pushes game items through ctx.teams.activity.push with source=. That +-- core writes here too is deliberate — the rendering path is exercised by core's +-- own content from day one, so the feed is never empty on a deployment whose +-- module pushes nothing. +-- +-- `summary` is ALREADY-RENDERED text and core never composes one (§4.1). Core +-- cannot phrase "gained 15,000 gold" for a game whose vocabulary it does not know, +-- and a core that templated it would have re-acquired exactly the game semantics +-- the module system exists to remove. `kind` and `payload` are likewise opaque: +-- core stores and filters them, and only the module's `team.overview` slot renders +-- anything richer than the text. +CREATE TABLE IF NOT EXISTS team_activity ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + team_id INT NOT NULL, + source VARCHAR(32) NOT NULL, -- 'core' or a module id + kind VARCHAR(64) NOT NULL, -- namespaced ., opaque to core + summary VARCHAR(255) NOT NULL, -- module-rendered; core never composes one + -- Defaults to 'members' — fail closed. The module CHOOSES visibility per item; + -- core ENFORCES it on the read path. Same shape as a module owning the + -- public-safety filter for its push streams (MODULE_API.md §2.4). + visibility ENUM('public','members') NOT NULL DEFAULT 'members', + actor_member_key VARCHAR(191) NULL, + actor_user_id INT NULL, + payload JSON NULL, -- opaque; rendered only by the module's slot + occurred_at DATETIME NOT NULL, -- when it happened in the game, not when it arrived + -- Optional idempotence key. INSERT IGNORE against this unique index is the same + -- trick shard_events already uses, and it is what makes a sidecar reconnect + -- backfill safe: replaying a window of events re-posts nothing. + dedupe_key CHAR(40) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_team_activity_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE, + -- Actor is SET NULL, not CASCADE (§2.10): deleting an account must not delete the + -- Team's history of what happened, only the attribution. + CONSTRAINT fk_team_activity_actor FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL, + UNIQUE KEY uq_team_activity_dedupe (team_id, dedupe_key), + INDEX idx_team_activity_feed (team_id, occurred_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + -- Migrations for databases created before the wiki upgrade. Each statement uses -- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get -- these columns from the CREATE TABLE above; existing installs get them here. diff --git a/server/src/model/teams/teamActivity.db.js b/server/src/model/teams/teamActivity.db.js new file mode 100644 index 0000000..efd6f48 --- /dev/null +++ b/server/src/model/teams/teamActivity.db.js @@ -0,0 +1,133 @@ +// SQL for the per-Team activity feed (TEAMS.md §4.2). Statements only; every +// decision about what a caller may SEE lives in teamActivity.model.js. + +const { query } = require('../../utils/db') + +const ACTIVITY_COLUMNS = ` + id, team_id, source, kind, summary, visibility, + actor_member_key, actor_user_id, payload, occurred_at, created_at` + +/** + * Insert one item, idempotently when it carries a dedupe key. + * + * INSERT IGNORE against uq_team_activity_dedupe is what makes replay safe: a + * sidecar reconnect backfills a window of events it already delivered, and + * without this every reconnect would double-post the feed. The same trick + * `shard_events` uses, for the same reason. + * + * The unique key is (team_id, dedupe_key) and MariaDB treats NULL as distinct in + * a unique index, so items WITHOUT a key never collide with each other — an + * un-keyed push is always an insert, which is the documented contract (§4.1: + * `dedupeKey` is optional and "makes replay idempotent", so omitting it opts out). + * + * IGNORE would also swallow a genuine error — a bad FK, an over-long summary. The + * model validates and truncates before calling, so what reaches here can only fail + * on the dedupe key, and `affectedRows` reports which happened. + */ +async function insert(item) { + const res = await query( + `INSERT IGNORE INTO team_activity + (team_id, source, kind, summary, visibility, actor_member_key, actor_user_id, payload, occurred_at, dedupe_key) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + item.teamId, + item.source, + item.kind, + item.summary, + item.visibility, + item.actorMemberKey, + item.actorUserId, + item.payload === null ? null : JSON.stringify(item.payload), + new Date(item.occurredAt), + item.dedupeKey, + ], + ) + return Number(res.affectedRows) > 0 +} + +/** + * One page of a Team's feed, already narrowed to the visibilities the caller may + * see. + * + * `visibilities` is always supplied by the model and never by a request + * parameter — a caller naming its own visibility filter is the whole bug this + * table's ENUM exists to prevent. Ordered newest first by `occurred_at`, the + * game's clock, not `created_at`: a backfill that arrives late still sorts where + * it happened. + */ +async function page(teamId, visibilities, { limit, offset }) { + const slots = visibilities.map(() => '?').join(', ') + return query( + `SELECT ${ACTIVITY_COLUMNS} FROM team_activity + WHERE team_id = ? AND visibility IN (${slots}) + ORDER BY occurred_at DESC, id DESC + LIMIT ? OFFSET ?`, + [teamId, ...visibilities, limit, offset], + ) +} + +/** Total matching rows, for the same filter — so a client can page honestly. */ +async function count(teamId, visibilities) { + const slots = visibilities.map(() => '?').join(', ') + const rows = await query( + `SELECT COUNT(*) AS n FROM team_activity WHERE team_id = ? AND visibility IN (${slots})`, + [teamId, ...visibilities], + ) + return Number(rows[0] ? rows[0].n : 0) +} + +/** Everything older than the retention horizon, across every Team. */ +async function deleteOlderThan(days) { + const res = await query( + 'DELETE FROM team_activity WHERE occurred_at < (NOW() - INTERVAL ? DAY)', + [days], + ) + return Number(res.affectedRows) || 0 +} + +/** + * Which Teams currently exceed the per-Team row cap, and by how much. + * + * Asked first so the trim only runs for Teams that need it. A feed fed by a game + * loop is the obvious unbounded-growth failure (§4.2), and on a shard with one + * busy guild and fifty quiet ones this keeps the nightly job proportional to the + * problem rather than to the number of Teams. + */ +async function overCap(cap) { + return query( + `SELECT team_id, COUNT(*) AS n FROM team_activity + GROUP BY team_id HAVING n > ?`, + [cap], + ) +} + +/** + * Trim one Team back to the newest `cap` rows. + * + * Expressed as "delete everything at or below the id of the cap-th newest row" + * rather than as a correlated subquery on the same table, which MariaDB refuses + * inside a DELETE (error 1093). The derived table is what makes it legal — the + * subquery is materialised before the delete runs. + */ +async function trimToCap(teamId, cap) { + const rows = await query( + `SELECT id FROM team_activity + WHERE team_id = ? ORDER BY occurred_at DESC, id DESC LIMIT 1 OFFSET ?`, + [teamId, cap], + ) + if (!rows[0]) return 0 + const res = await query( + 'DELETE FROM team_activity WHERE team_id = ? AND id <= ?', + [teamId, rows[0].id], + ) + return Number(res.affectedRows) || 0 +} + +module.exports = { + insert, + page, + count, + deleteOlderThan, + overCap, + trimToCap, +} diff --git a/server/src/model/teams/teamActivity.model.js b/server/src/model/teams/teamActivity.model.js new file mode 100644 index 0000000..f6d0e84 --- /dev/null +++ b/server/src/model/teams/teamActivity.model.js @@ -0,0 +1,312 @@ +// ── The per-Team activity feed (TEAMS.md Part 4) ─────────────────────────── +// +// Two writers, one table. A module pushes game items through +// `ctx.teams.activity.push` (§4.1); core writes its own membership and rename +// items directly (§4.2). Both land in `team_activity` with a `source`, and the +// read path treats them identically — which is the point of core writing here at +// all, since it means the rendering path is exercised from day one on a +// deployment whose module pushes nothing. +// +// **Three rules shape this file.** +// +// 1. *Core never composes a summary.* `summary` arrives already rendered and is +// stored verbatim (§4.1). Core cannot phrase "gained 15,000 gold" for a game +// whose vocabulary it does not know, and a core that templated it would have +// re-acquired the game semantics the module system exists to remove. Core's OWN +// five kinds are the sole exception, and they are about membership and renames +// — platform facts, not game ones. +// +// 2. *Visibility fails closed.* An item with no stated visibility is `members`, +// and the read path resolves what a caller may see from their access rather +// than from anything they send. +// +// 3. *A push never throws at its call site.* `ctx.teams.activity.push` is awaited +// by a module inside a game-event handler. A bad item is dropped and logged; +// an unknown Team is dropped and logged. The alternative — rejecting the batch +// — makes core's storage problem into the module's control flow, and the +// contract (MODULE_API.md §2.3) is that ctx pushes are fire-and-forget. + +const activityDb = require('./teamActivity.db') +const teamsDb = require('./teams.db') +const access = require('./teamAccess.model') +const settings = require('../settings/settings.model') + +const log = require('../../utils/logger')('teams') + +// Column widths from schema.sql. Truncating rather than refusing: an over-long +// summary is a module being verbose, not a module being wrong, and dropping the +// item would lose a real event over a display detail. +const MAX_SUMMARY = 255 +const MAX_KIND = 64 +const MAX_MEMBER_KEY = 191 +// CHAR(40) — a sha1 hex is the natural fit and what §4.1's example looks like, +// but the column is opaque and any stable string within the width works. +const MAX_DEDUPE = 40 + +const VISIBILITIES = ['public', 'members'] + +// Retention (§4.2). Both are settings so an operator can tighten a busy shard +// without a deploy; the defaults are the doc's. +const DEFAULT_RETAIN_DAYS = 90 +const DEFAULT_ROW_CAP = 2000 + +/** + * Core's own kinds (§4.2). + * + * `core.forum.thread` is named in the doc and lands with the forum in phase 4 — + * there is nothing to emit it from yet. The four here are all core knows how to + * say without asking a game anything. + */ +const CORE_KINDS = { + MEMBER_JOINED: 'core.member.joined', + MEMBER_LEFT: 'core.member.left', + LEADER_CHANGED: 'core.leader.changed', + TEAM_RENAMED: 'core.team.renamed', +} + +const clamp = (v, max) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, max) : null) + +/** + * Normalise one pushed item, or return null to drop it. + * + * `teamId` is resolved by the caller, not carried on the item: a module names its + * own `externalId` and core maps it (§4.1), so a module can never write into + * another module's Team by guessing an integer. + */ +function normalise(item, source, teamId) { + if (!item || typeof item !== 'object') return null + + const kind = clamp(item.kind, MAX_KIND) + const summary = clamp(item.summary, MAX_SUMMARY) + // Both are load-bearing and neither has a safe default: an item with no kind + // cannot be filtered or rendered by a slot, and one with no summary is a blank + // row on a public page. + if (!kind || !summary) return null + + // `occurredAt` is the game's clock and the feed's sort key. A missing or + // unparseable one becomes now — the item is real even when its timestamp is + // not, and dropping it would lose an event over metadata. + const occurredAt = Number.isFinite(item.occurredAt) ? Number(item.occurredAt) : Date.now() + + return { + teamId, + source, + kind, + summary, + visibility: VISIBILITIES.includes(item.visibility) ? item.visibility : 'members', + actorMemberKey: clamp(item.actorMemberKey, MAX_MEMBER_KEY), + // Resolved BY THE MODULE, like every other user id crossing this boundary + // (§2.3) — core takes the number and never looks it up. + actorUserId: Number.isInteger(item.actorUserId) && item.actorUserId > 0 ? item.actorUserId : null, + payload: item.payload && typeof item.payload === 'object' ? item.payload : null, + occurredAt, + dedupeKey: clamp(item.dedupeKey, MAX_DEDUPE), + } +} + +/** + * `ctx.teams.activity.push` — a module's whole write access to the feed. + * + * Items name their Team by the module's own `externalId`, and only ACTIVE Teams + * owned by THAT module resolve. An archived Team is deliberately not writable: its + * feed is a read-only record of what happened before the rename or the disband + * (§2.2), and letting a late-arriving event append to it would make a closed + * record grow. + * + * Returns the number of items actually stored. Dropped items are logged with the + * reason and never raised — see rule 3 above. + */ +async function push(source, items) { + if (!Array.isArray(items)) { + log.warn('teams activity push: not an array', { source }) + return 0 + } + if (!items.length) return 0 + + // One lookup per distinct externalId, not one per item: a champion spawn + // completing pushes a batch for a single Team, and re-resolving it per item + // would be a query per row. + const teamIds = new Map() + let stored = 0 + let dropped = 0 + + for (const item of items) { + const externalId = item && typeof item.externalId === 'string' ? item.externalId.trim() : '' + if (!externalId) { dropped += 1; continue } + + if (!teamIds.has(externalId)) { + // eslint-disable-next-line no-await-in-loop + const row = await teamsDb.findActive(source, externalId) + teamIds.set(externalId, row ? row.id : null) + } + const teamId = teamIds.get(externalId) + if (!teamId) { dropped += 1; continue } + + const normalised = normalise(item, source, teamId) + if (!normalised) { dropped += 1; continue } + + // eslint-disable-next-line no-await-in-loop + const inserted = await activityDb.insert(normalised) + // A dedupe collision is a SUCCESSFUL no-op, not a drop — it is the mechanism + // working. Counted as stored so a module replaying a backfill does not read + // its own idempotence as data loss. + if (inserted) stored += 1 + } + + if (dropped) { + log.warn('teams activity push: dropped items', { source, dropped, offered: items.length }) + } + return stored +} + +/** + * Core's own write path (§4.2), used by the reconciler and the rename rule. + * + * Separate from `push` because core names a Team by its own primary key — it is + * already holding the row — and because core's items are always `public`: a + * member joining or a Team being renamed is exactly what a public Team page is + * for. Nothing here is game vocabulary. + */ +async function logCore({ teamId, kind, summary, actorMemberKey = null, actorUserId = null, occurredAt = Date.now(), dedupeKey = null }) { + if (!teamId || !kind || !summary) return false + return activityDb.insert({ + teamId, + source: 'core', + kind: clamp(kind, MAX_KIND), + summary: clamp(summary, MAX_SUMMARY), + visibility: 'public', + actorMemberKey: clamp(actorMemberKey, MAX_MEMBER_KEY), + actorUserId: Number.isInteger(actorUserId) && actorUserId > 0 ? actorUserId : null, + payload: null, + occurredAt, + dedupeKey: clamp(dedupeKey, MAX_DEDUPE), + }) +} + +/** + * Which visibilities a caller may see (§4.3). + * + * `members` items go to members and to forum-granted users — the same two + * authority paths `forumAccess` already resolves, reused rather than re-derived + * so the feed can never disagree with the forum about who is inside a Team. + * Anyone else, including every anonymous caller, sees `public` only. + */ +async function visibilitiesFor(teamId, userId) { + if (!userId) return ['public'] + const resolved = await access.forumAccess(teamId, userId) + return resolved.allowed ? ['public', 'members'] : ['public'] +} + +/** The rendered shape. `payload` rides along for the module's slot (§4.3). */ +function publicItem(row) { + return { + id: Number(row.id), + source: row.source, + kind: row.kind, + summary: row.summary, + visibility: row.visibility, + occurredAt: row.occurred_at, + payload: row.payload ?? null, + } +} + +/** + * One page of a Team's feed for one viewer. + * + * A HIDDEN Team's feed is not served publicly, for the same reason its roster is + * not (§2.8.3): hidden means absent from every public surface, and a feed that + * answered while the page 404s would republish the suppressed name in every + * `core.team.renamed` summary. + */ +async function feedFor(slug, userId, { limit = 50, offset = 0 } = {}) { + const row = await teamsDb.findBySlug(slug) + if (!row) return null + + const visibilities = await visibilitiesFor(row.id, userId) + // A member of a hidden Team still sees its feed — suppression is a + // public-surface rule, and a member is not a member of the public (§2.11). + if (row.hidden && visibilities.length === 1) return null + + const [rows, total] = await Promise.all([ + activityDb.page(row.id, visibilities, { limit, offset }), + activityDb.count(row.id, visibilities), + ]) + return { + items: rows.map(publicItem), + total, + limit, + offset, + // So a client can render "members-only items are hidden" rather than + // presenting a filtered feed as the whole one. + scope: visibilities.includes('members') ? 'members' : 'public', + } +} + +// ── Retention (§4.2) ─────────────────────────────────────────────────────── + +const RETAIN_KEY = 'team_activity_retain_days' +const CAP_KEY = 'team_activity_row_cap' + +/** + * Read both limits, falling back to the defaults on anything unreadable. + * + * Wrapped in a try like `teamSync.intervalSeconds`, and for the same reason: this + * runs on a timer with nobody watching, and a settings table that is briefly + * unavailable must yield the default rather than an exception that kills the + * nightly job. A misconfigured value fails the same way — a zero or a negative + * retention would delete the whole feed, so it is rejected rather than honoured. + */ +async function retentionConfig() { + let rawDays + let rawCap + try { + ;[rawDays, rawCap] = await Promise.all([settings.get(RETAIN_KEY), settings.get(CAP_KEY)]) + } catch { + return { days: DEFAULT_RETAIN_DAYS, cap: DEFAULT_ROW_CAP } + } + const days = Number.parseInt(rawDays, 10) + const cap = Number.parseInt(rawCap, 10) + return { + days: Number.isFinite(days) && days > 0 ? days : DEFAULT_RETAIN_DAYS, + cap: Number.isFinite(cap) && cap > 0 ? cap : DEFAULT_ROW_CAP, + } +} + +/** + * The nightly prune: an age horizon AND a per-Team row cap. + * + * Both, because either alone has a hole. Age alone lets one busy guild write a + * million rows inside the window; a cap alone keeps a dead Team's feed forever. + * Unbounded growth on a per-Team feed fed by a game loop is the obvious failure + * here and it is cheaper to bound it now than to discover it at cutover. + */ +async function prune() { + const { days, cap } = await retentionConfig() + const byAge = await activityDb.deleteOlderThan(days) + + let byCap = 0 + const over = await activityDb.overCap(cap) + for (const row of over) { + // eslint-disable-next-line no-await-in-loop + byCap += await activityDb.trimToCap(row.team_id, cap) + } + + if (byAge || byCap) log.info('teams activity prune', { byAge, byCap, days, cap }) + return { byAge, byCap, days, cap } +} + +module.exports = { + push, + logCore, + feedFor, + visibilitiesFor, + publicItem, + prune, + retentionConfig, + RETAIN_KEY, + CAP_KEY, + CORE_KINDS, + VISIBILITIES, + DEFAULT_RETAIN_DAYS, + DEFAULT_ROW_CAP, +} diff --git a/server/src/model/teams/teamSync.model.js b/server/src/model/teams/teamSync.model.js index 1847470..067f41f 100644 --- a/server/src/model/teams/teamSync.model.js +++ b/server/src/model/teams/teamSync.model.js @@ -32,6 +32,7 @@ const teamsDb = require('./teams.db') const teamProvider = require('./teamProvider') const moderation = require('./teamModeration.model') +const activity = require('./teamActivity.model') const { slugify, uniqueSlug } = require('./teamSlug') const settings = require('../settings/settings.model') const log = require('../../utils/logger')('teams') @@ -130,9 +131,66 @@ async function applyRename(moduleId, existing, team) { log.info('team renamed; previous row archived', { externalId: team.externalId, from: existing.name, to: team.name, archivedId: existing.id, successorId, }) + // §4.2's `core.team.renamed`, written to the SUCCESSOR rather than to the row + // that was renamed: the archived row is a read-only record of what happened + // before the rename (§2.2), and the person who wants to know a Team used to be + // called something else is looking at the live page. + // + // The old name is core's own, not game-sourced text a module handed us this + // run — it is the `name` column core has been serving all along — so §2.9's + // approval gate does not apply. It can still be a name staff suppressed, which + // is why a hidden Team's feed is not served publicly (teamActivity.feedFor). + await activity.logCore({ + teamId: successorId, + kind: activity.CORE_KINDS.TEAM_RENAMED, + summary: `Renamed from ${existing.display_name_override || existing.name}`, + dedupeKey: `renamed:${existing.id}`, + }).catch((err) => log.warn('rename activity not recorded', { message: err.message })) return successorId } +/** Never a game-internal member key on a public page: that identifier is not published (§3.2). */ +const memberLabel = (row) => (row && row.display_name) || 'A member' + +/** + * Core's own membership items for one roster run (§4.2). + * + * **Suppressed on a Team's FIRST roster.** Importing a 155-member guild is one + * Team arriving, not 155 people joining, and emitting a join per member would + * bury every real event under the import and blow through the row cap on day one. + * `roster_synced_at IS NULL` is exactly "core has never held a roster for this + * Team", so the same condition covers a newly created Team and a newly installed + * module adopting an existing one. + * + * Never throws: the feed is a rendering of the sync, and a feed write failing + * must not abort the sync that is the actual source of truth. + */ +async function logRosterActivity(team, { joined, left, promoted, demoted }) { + if (!team.roster_synced_at) return + + const items = [ + ...joined.map((row) => ({ kind: activity.CORE_KINDS.MEMBER_JOINED, row, verb: 'joined' })), + ...left.map((row) => ({ kind: activity.CORE_KINDS.MEMBER_LEFT, row, verb: 'left' })), + ...promoted.map((row) => ({ kind: activity.CORE_KINDS.LEADER_CHANGED, row, verb: 'became a leader' })), + ...demoted.map((row) => ({ kind: activity.CORE_KINDS.LEADER_CHANGED, row, verb: 'stepped down as a leader' })), + ] + + for (const { kind, row, verb } of items) { + try { + // eslint-disable-next-line no-await-in-loop + await activity.logCore({ + teamId: team.id, + kind, + summary: `${memberLabel(row)} ${verb}`, + actorMemberKey: row.member_key, + actorUserId: row.user_id ?? null, + }) + } catch (err) { + log.warn('roster activity not recorded', { teamId: team.id, kind, message: err.message }) + } + } +} + /** * Sync one Team's roster and leadership. Gates 3 and 4 live here. * @@ -152,7 +210,13 @@ async function syncRoster(team) { return false } - const known = await teamsDb.memberKeys(team.id) + // The full rows rather than just the keys: the activity feed needs the display + // name and the prior `is_leader` of everyone who is about to change, and both + // are gone once the upsert below has run. One read either way — this replaces + // the `memberKeys` call rather than adding to it. + const knownRows = await teamsDb.membersByTeam(team.id) + const knownByKey = new Map(knownRows.map((row) => [row.member_key, row])) + const known = knownRows.map((row) => row.member_key) // Gate 4, the per-Team twin of gate 2. if (answer.complete && answer.members.length === 0 && known.length > 0) { @@ -184,17 +248,35 @@ async function syncRoster(team) { }) } + // Anyone the module reports that core was not already holding. Read from the + // module's shape, since a joiner has no row yet. + const joined = answer.members + .filter((m) => !knownByKey.has(m.memberKey)) + .map((m) => ({ member_key: m.memberKey, display_name: m.displayName, user_id: m.userId })) + // Removals only from a COMPLETE answer. `complete: false` means "valid but // partial", so additions and updates apply and nothing is taken away. + let left = [] if (answer.complete) { const seen = new Set(answer.members.map((m) => m.memberKey)) - await teamsDb.markDeparted(team.id, known.filter((key) => !seen.has(key))) + const departedKeys = known.filter((key) => !seen.has(key)) + left = departedKeys.map((key) => knownByKey.get(key)) + await teamsDb.markDeparted(team.id, departedKeys) } // Leadership is a separate question with a separate answer, and a provider that // cannot answer it leaves the synced value alone rather than demoting everyone. const leaders = await teamProvider.getTeamLeaders(team.external_id) + let promoted = [] + let demoted = [] if (leaders.ok) { + // Diffed against the PRIOR rows, before setLeaders overwrites them. A member + // who joined this run as a leader is reported as joining, not as being + // promoted — they were never anything else here. + const nowLeader = new Set(leaders.leaders) + const departed = new Set(left.map((row) => row && row.member_key)) + promoted = knownRows.filter((row) => nowLeader.has(row.member_key) && !row.is_leader) + demoted = knownRows.filter((row) => !nowLeader.has(row.member_key) && row.is_leader && !departed.has(row.member_key)) await teamsDb.setLeaders(team.id, leaders.leaders) } else { log.warn('leadership left untouched; provider could not answer', { @@ -203,6 +285,8 @@ async function syncRoster(team) { } await teamsDb.recount(team.id) + // Read before `markRosterSynced` moves the stamp this decision turns on. + await logRosterActivity(team, { joined, left: left.filter(Boolean), promoted, demoted }) await teamsDb.markRosterSynced(team.id) return true } diff --git a/server/src/modules/loader.js b/server/src/modules/loader.js index c1d4370..9d42d1e 100644 --- a/server/src/modules/loader.js +++ b/server/src/modules/loader.js @@ -120,6 +120,7 @@ function buildCtx(id, moduleRoot) { const activity = require('../model/activity/activity.model') const users = require('../model/users/users.model') const teams = require('../model/teams/teamSync.model') + const teamActivity = require('../model/teams/teamActivity.model') const { makeLimiter, accountChangeLimiter } = require('../middleware/rateLimit') /* eslint-enable global-require */ @@ -190,14 +191,20 @@ function buildCtx(id, moduleRoot) { 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. + // §4's activity feed (phase 3). `source` is bound to the CALLING module and + // is never taken from the item — a module writes its own items, under its + // own name, and items name their Team by the module's own `externalId`, so + // there is no id a module could send that reaches another module's Team. + // + // Like `publish` and `reconcile` above, a failure here never reaches the + // module: this is called from inside a game-event handler, and a storage + // problem of core's must not become the module's control flow. A rejected + // write is logged and the promise still resolves. activity: { - push: () => { - throw new Error('ctx.teams.activity.push is not available until the Team activity feed lands (TEAMS.md §4)') - }, + push: (items) => teamActivity.push(id, items).then( + (stored) => { void stored }, + (err) => { log.error('ctx.teams.activity.push failed', { module: id, message: err.message }) }, + ), }, }, // One function, for one caller: the `admin.users.detail` slot router needs diff --git a/server/src/server.js b/server/src/server.js index 9de900a..4474214 100644 --- a/server/src/server.js +++ b/server/src/server.js @@ -9,6 +9,7 @@ const http = require('http') // now because none of it reaches the loader's scan. const botScore = require('./middleware/botScore') const announceWorker = require('./utils/announceWorker') +const teamActivityPrune = require('./utils/teamActivityPrune') const { ensureSchema, close } = require('./utils/db') const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed') const settings = require('./model/settings/settings.model') @@ -150,6 +151,11 @@ async function start() { // retry per leg. No-op until a news post is actually published. announceWorker.start() + // Bound the per-Team activity feed (TEAMS.md §4.2). A feed fed by a game loop + // is the obvious unbounded-growth failure, so retention starts with the feed + // rather than after someone notices. No-op on a deployment with no Teams. + teamActivityPrune.start() + setupShutdown(server, internalServer) } @@ -167,6 +173,7 @@ function setupShutdown(server, internalServer) { await moduleLifecycle.shutdown() botScore.stopSweeper() // stop the bot-store cleanup interval announceWorker.stop() // stop the news-announcement dispatcher poller + teamActivityPrune.stop() // stop the Team activity retention timer server.close(() => log.info('http server closed')) if (internalServer) internalServer.close(() => log.info('internal http server closed')) try { diff --git a/server/src/utils/teamActivityPrune.js b/server/src/utils/teamActivityPrune.js new file mode 100644 index 0000000..6f2e71e --- /dev/null +++ b/server/src/utils/teamActivityPrune.js @@ -0,0 +1,64 @@ +// ── Team activity retention worker ────────────────────────────────────────── +// +// TEAMS.md §4.2's nightly prune: an age horizon (`team_activity_retain_days`, +// default 90) and a per-Team row cap (`team_activity_row_cap`, default 2000). +// Both live in `settings`, so an operator can tighten a busy shard without a +// deploy. +// +// Same in-process shape as utils/announceWorker and middleware/botScore's +// sweeper — setInterval + unref + stop(), wired into server.js start/shutdown. +// There is no cron in this stack and adding one for a single daily DELETE would +// be a dependency to justify at every future upgrade. +// +// **The first run is delayed rather than immediate.** A prune at boot would put a +// table-wide DELETE in front of the first request on every restart, and a +// deployment that is crash-looping would run it on every loop. Five minutes in is +// past the point where a boot has either succeeded or failed. + +const teamActivity = require('../model/teams/teamActivity.model') +const log = require('./logger')('teams') + +// Nightly, per §4.2. Not aligned to a wall-clock hour: the work is proportional +// to what arrived rather than to when it is done, and pinning it to 03:00 would +// mean a process that restarts each afternoon never prunes at all. +const INTERVAL_MS = Number(process.env.TEAM_ACTIVITY_PRUNE_MS) || 24 * 60 * 60 * 1000 +const FIRST_RUN_MS = Number(process.env.TEAM_ACTIVITY_PRUNE_DELAY_MS) || 5 * 60 * 1000 + +let timer = null +let firstRun = null + +/** One prune. Never throws — it runs on a timer with nobody to catch it. */ +async function tick() { + try { + return await teamActivity.prune() + } catch (err) { + log.error('team activity prune failed', { message: err.message }) + return null + } +} + +function start() { + if (timer || firstRun) return timer + firstRun = setTimeout(() => { + firstRun = null + tick() + timer = setInterval(() => { tick() }, INTERVAL_MS) + if (timer.unref) timer.unref() + }, FIRST_RUN_MS) + if (firstRun.unref) firstRun.unref() + log.info('team activity retention started', { intervalMs: INTERVAL_MS, firstRunMs: FIRST_RUN_MS }) + return timer +} + +function stop() { + if (firstRun) { + clearTimeout(firstRun) + firstRun = null + } + if (timer) { + clearInterval(timer) + timer = null + } +} + +module.exports = { start, stop, tick, INTERVAL_MS, FIRST_RUN_MS } diff --git a/server/test/teamActivity.test.js b/server/test/teamActivity.test.js new file mode 100644 index 0000000..58fb32b --- /dev/null +++ b/server/test/teamActivity.test.js @@ -0,0 +1,271 @@ +// The per-Team activity feed (docs/website/TEAMS.md Part 4). +// +// The db layer is stubbed and an in-memory table stands in for `team_activity`, +// so these are assertions about the RULES: what a module is allowed to write, +// what a caller is allowed to see, and what the prune takes away. The three worth +// protecting are the ones that are easy to "simplify" into a leak: +// +// 1. a module writes only into its OWN active Teams, named by external id; +// 2. visibility defaults to `members` and is resolved from the session, never +// from a request parameter; +// 3. a hidden Team's feed does not answer a public caller. +const { test, beforeEach, afterEach } = require('node:test') +const assert = require('node:assert/strict') + +const activityDb = require('../src/model/teams/teamActivity.db') +const teamsDb = require('../src/model/teams/teams.db') +const access = require('../src/model/teams/teamAccess.model') +const settings = require('../src/model/settings/settings.model') +const activity = require('../src/model/teams/teamActivity.model') + +let store +const saved = new Map() + +function patch(mod, name, fn) { + if (!saved.has(mod)) saved.set(mod, new Map()) + if (!saved.get(mod).has(name)) saved.get(mod).set(name, mod[name]) + mod[name] = fn +} + +function restore() { + for (const [mod, names] of saved) for (const [name, fn] of names) mod[name] = fn + saved.clear() +} + +function stub() { + store = { + rows: [], + nextId: 1, + teams: [ + { id: 1, module_id: 'uo', external_id: 'g1', slug: 'the-guild', hidden: 0, status: 'active' }, + { id: 2, module_id: 'uo', external_id: 'g2', slug: 'hidden-guild', hidden: 1, status: 'active' }, + ], + allowed: new Set(), // userIds with member/grant access, keyed "teamId:userId" + } + + patch(teamsDb, 'findActive', async (moduleId, externalId) => + store.teams.find((t) => t.module_id === moduleId && t.external_id === externalId && t.status === 'active')) + patch(teamsDb, 'findBySlug', async (slug) => store.teams.find((t) => t.slug === slug)) + + patch(access, 'forumAccess', async (teamId, userId) => ({ + allowed: store.allowed.has(`${teamId}:${userId}`), + viaMembership: store.allowed.has(`${teamId}:${userId}`), + viaGrant: false, + isLeader: false, + })) + + patch(activityDb, 'insert', async (item) => { + if (item.dedupeKey && store.rows.some((r) => r.team_id === item.teamId && r.dedupe_key === item.dedupeKey)) { + return false // the unique index doing its job + } + store.rows.push({ + id: store.nextId++, + team_id: item.teamId, + source: item.source, + kind: item.kind, + summary: item.summary, + visibility: item.visibility, + actor_member_key: item.actorMemberKey, + actor_user_id: item.actorUserId, + payload: item.payload, + occurred_at: new Date(item.occurredAt), + dedupe_key: item.dedupeKey, + }) + return true + }) + + patch(activityDb, 'page', async (teamId, visibilities, { limit, offset }) => + store.rows + .filter((r) => r.team_id === teamId && visibilities.includes(r.visibility)) + .sort((a, b) => b.occurred_at - a.occurred_at || b.id - a.id) + .slice(offset, offset + limit)) + + patch(activityDb, 'count', async (teamId, visibilities) => + store.rows.filter((r) => r.team_id === teamId && visibilities.includes(r.visibility)).length) + + patch(activityDb, 'deleteOlderThan', async (days) => { + const cutoff = Date.now() - days * 86400_000 + const before = store.rows.length + store.rows = store.rows.filter((r) => r.occurred_at.getTime() >= cutoff) + return before - store.rows.length + }) + + patch(activityDb, 'overCap', async (cap) => { + const byTeam = new Map() + for (const r of store.rows) byTeam.set(r.team_id, (byTeam.get(r.team_id) || 0) + 1) + return [...byTeam].filter(([, n]) => n > cap).map(([team_id, n]) => ({ team_id, n })) + }) + + patch(activityDb, 'trimToCap', async (teamId, cap) => { + const mine = store.rows + .filter((r) => r.team_id === teamId) + .sort((a, b) => b.occurred_at - a.occurred_at || b.id - a.id) + const keep = new Set(mine.slice(0, cap).map((r) => r.id)) + const before = store.rows.length + store.rows = store.rows.filter((r) => r.team_id !== teamId || keep.has(r.id)) + return before - store.rows.length + }) + + patch(settings, 'get', async () => null) // defaults +} + +const item = (extra = {}) => ({ externalId: 'g1', kind: 'uo.thing', summary: 'A thing happened', ...extra }) + +beforeEach(stub) +afterEach(restore) + +// ── What a module may write ──────────────────────────────────────────────── + +test('a pushed item lands against the team its external id names', async () => { + const stored = await activity.push('uo', [item()]) + assert.equal(stored, 1) + assert.equal(store.rows[0].team_id, 1) + assert.equal(store.rows[0].source, 'uo') +}) + +test('a module cannot write into another module\'s team', async () => { + // 'other' owns no team with external id g1, so there is nothing to resolve — + // and no integer the module could have sent instead, which is the point of + // naming Teams by external id on this path. + const stored = await activity.push('other', [item()]) + assert.equal(stored, 0) + assert.equal(store.rows.length, 0) +}) + +test('an unknown external id is dropped rather than raised', async () => { + const stored = await activity.push('uo', [item({ externalId: 'nope' })]) + assert.equal(stored, 0) +}) + +test('visibility defaults to members, and an unknown value does not widen it', async () => { + await activity.push('uo', [item(), item({ visibility: 'everyone' }), item({ visibility: 'public' })]) + assert.deepEqual(store.rows.map((r) => r.visibility), ['members', 'members', 'public']) +}) + +test('an item with no kind or no summary is dropped, and the rest of the batch still lands', async () => { + const stored = await activity.push('uo', [item({ kind: '' }), item({ summary: ' ' }), item()]) + assert.equal(stored, 1) + assert.equal(store.rows.length, 1) +}) + +test('an over-long summary is truncated rather than losing the event', async () => { + await activity.push('uo', [item({ summary: 'x'.repeat(400) })]) + assert.equal(store.rows[0].summary.length, 255) +}) + +test('a replayed batch with dedupe keys stores each item once', async () => { + const batch = [item({ dedupeKey: 'champ:77' }), item({ dedupeKey: 'champ:78' })] + await activity.push('uo', batch) + await activity.push('uo', batch) // the sidecar reconnect backfill + assert.equal(store.rows.length, 2) +}) + +test('items without a dedupe key are never collapsed into each other', async () => { + await activity.push('uo', [item(), item()]) + assert.equal(store.rows.length, 2) +}) + +test('a push is never rejected for being malformed at the top level', async () => { + assert.equal(await activity.push('uo', null), 0) + assert.equal(await activity.push('uo', []), 0) +}) + +// ── What a caller may see ────────────────────────────────────────────────── + +test('an anonymous caller gets public items only, and is told the scope', async () => { + await activity.push('uo', [item({ visibility: 'public' }), item({ visibility: 'members' })]) + const feed = await activity.feedFor('the-guild', null) + assert.equal(feed.items.length, 1) + assert.equal(feed.items[0].visibility, 'public') + assert.equal(feed.scope, 'public') + // `total` is the caller's total, not the table's — otherwise paging lies. + assert.equal(feed.total, 1) +}) + +test('a member sees both, via the same resolver the forum uses', async () => { + await activity.push('uo', [item({ visibility: 'public' }), item({ visibility: 'members' })]) + store.allowed.add('1:7') + const feed = await activity.feedFor('the-guild', 7) + assert.equal(feed.items.length, 2) + assert.equal(feed.scope, 'members') +}) + +test('an authenticated non-member is exactly an anonymous caller here', async () => { + await activity.push('uo', [item({ visibility: 'members' })]) + const feed = await activity.feedFor('the-guild', 99) + assert.equal(feed.items.length, 0) + assert.equal(feed.scope, 'public') +}) + +test('a hidden team\'s feed does not answer the public, but does answer its members', async () => { + await activity.push('uo', [item({ externalId: 'g2', visibility: 'public' })]) + assert.equal(await activity.feedFor('hidden-guild', null), null) + + store.allowed.add('2:7') + const feed = await activity.feedFor('hidden-guild', 7) + assert.equal(feed.items.length, 1) +}) + +test('an unknown slug is not found rather than empty', async () => { + assert.equal(await activity.feedFor('no-such-team', null), null) +}) + +test('the rendered item carries the payload and never the actor identifiers', async () => { + await activity.push('uo', [item({ + visibility: 'public', payload: { serial: '0x77' }, actorMemberKey: '0x40012ab3', actorUserId: 7, + })]) + const feed = await activity.feedFor('the-guild', null) + assert.deepEqual(feed.items[0].payload, { serial: '0x77' }) + assert.equal('actorMemberKey' in feed.items[0], false) + assert.equal('actorUserId' in feed.items[0], false) +}) + +// ── Core's own items ─────────────────────────────────────────────────────── + +test('core writes as source=core and public', async () => { + await activity.logCore({ teamId: 1, kind: activity.CORE_KINDS.MEMBER_JOINED, summary: 'Aldric joined' }) + assert.equal(store.rows[0].source, 'core') + assert.equal(store.rows[0].visibility, 'public') +}) + +test('core refuses an item with nothing to say', async () => { + assert.equal(await activity.logCore({ teamId: 1, kind: 'core.x' }), false) + assert.equal(store.rows.length, 0) +}) + +// ── Retention ────────────────────────────────────────────────────────────── + +test('the prune drops rows past the age horizon', async () => { + const old = Date.now() - 100 * 86400_000 + await activity.push('uo', [item({ occurredAt: old }), item()]) + const res = await activity.prune() + assert.equal(res.days, activity.DEFAULT_RETAIN_DAYS) + assert.equal(res.byAge, 1) + assert.equal(store.rows.length, 1) +}) + +test('the prune trims a team back to the row cap, newest kept', async () => { + patch(settings, 'get', async (key) => (key === activity.CAP_KEY ? '3' : null)) + const base = Date.now() + for (let i = 0; i < 6; i++) { + // eslint-disable-next-line no-await-in-loop + await activity.push('uo', [item({ summary: `event ${i}`, occurredAt: base + i * 1000 })]) + } + const res = await activity.prune() + assert.equal(res.byCap, 3) + assert.deepEqual(store.rows.map((r) => r.summary), ['event 3', 'event 4', 'event 5']) +}) + +test('a zero or negative retention setting is rejected rather than emptying the feed', async () => { + patch(settings, 'get', async (key) => (key === activity.RETAIN_KEY ? '0' : null)) + await activity.push('uo', [item()]) + const res = await activity.prune() + assert.equal(res.days, activity.DEFAULT_RETAIN_DAYS) + assert.equal(store.rows.length, 1) +}) + +test('an unreadable settings table leaves the defaults standing', async () => { + patch(settings, 'get', async () => { throw new Error('pool down') }) + const res = await activity.retentionConfig() + assert.deepEqual(res, { days: activity.DEFAULT_RETAIN_DAYS, cap: activity.DEFAULT_ROW_CAP }) +}) diff --git a/server/test/teamSync.test.js b/server/test/teamSync.test.js index 614f986..26ea2aa 100644 --- a/server/test/teamSync.test.js +++ b/server/test/teamSync.test.js @@ -12,6 +12,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 activity = require('../src/model/teams/teamActivity.model') const settings = require('../src/model/settings/settings.model') const teamSync = require('../src/model/teams/teamSync.model') @@ -113,6 +114,16 @@ function stubDb() { patch(teamsDb, 'memberKeys', async (teamId) => [...membersOf(teamId).values()].filter((m) => m.status === 'active').map((m) => m.member_key)) + // The sync reads the full rows, not just the keys: the activity feed needs each + // changing member's display name and PRIOR is_leader, both of which the upsert + // is about to overwrite. Stubbing this is not optional — an unstubbed seam here + // reaches the real pool, and the symptom is the suite hanging on dead-pool + // retries rather than failing (see test/_setup.js). + patch(teamsDb, 'membersByTeam', async (teamId, { includeDeparted = false } = {}) => + [...membersOf(teamId).values()] + .filter((m) => includeDeparted || m.status === 'active') + .map((m) => ({ ...m }))) + patch(teamsDb, 'upsertMember', async (m) => { const existing = membersOf(m.teamId).get(m.memberKey) membersOf(m.teamId).set(m.memberKey, { @@ -182,6 +193,16 @@ function stubDb() { return { hidden: false } }) patch(moderation, 'rescreen', async () => 0) + + // The activity feed is its own unit (teamActivity.test.js); here it is captured + // so the reconciler's side of §4.2 can be asserted without a database. Stubbing + // the MODEL rather than the db layer keeps these tests about which items the + // sync decides to emit, which is the reconciler's half of the contract. + store.activity = [] + patch(activity, 'logCore', async (item) => { + store.activity.push(item) + return true + }) } // A provider whose answers the test controls. Defaults are authoritative and @@ -802,3 +823,155 @@ test('start() is inert with no provider registered', async () => { await teamSync.start() assert.equal(store.teams.length, 0) }) + +// ── Core's own activity items (§4.2) ─────────────────────────────────────── +// +// The reconciler's half of the feed: which items it DECIDES to emit. The feed's +// own rules — visibility, dedupe, retention — live in teamActivity.test.js. + +const kinds = () => store.activity.map((a) => a.kind) +const summaries = () => store.activity.map((a) => a.summary) + +const withMembers = (members, leaders = []) => ({ + getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }), + getTeamMembers: async () => ({ ok: true, members }), + getTeamLeaders: async () => ({ ok: true, leaders }), +}) + +// Re-provide between runs: the tests above establish that a provider registers +// once, so a second answer means a fresh registration. +async function resync(overrides, reason) { + registries._reset() + provide(overrides) + return teamSync.reconcileNow(reason) +} + +test('the FIRST roster emits nothing — an import is not 155 people joining', async () => { + provide(withMembers([member('0x1'), member('0x2')])) + await teamSync.reconcileNow('setup') + assert.equal(activeMembers(1).length, 2, 'the members did land') + assert.deepEqual(store.activity, [], 'and none of them was announced') +}) + +test('a member arriving after the first roster is announced', async () => { + provide(withMembers([member('0x1')])) + await teamSync.reconcileNow('setup') + + await resync(withMembers([member('0x1'), member('0x2', { displayName: 'Brenna' })]), 'test') + + assert.deepEqual(kinds(), ['core.member.joined']) + assert.deepEqual(summaries(), ['Brenna joined']) + assert.equal(store.activity[0].actorMemberKey, '0x2') +}) + +test('a member who leaves is announced by the name core last knew them by', async () => { + provide(withMembers([member('0x1'), member('0x2', { displayName: 'Brenna' })])) + await teamSync.reconcileNow('setup') + + await resync(withMembers([member('0x1')]), 'test') + + // The module no longer mentions them at all, so the display name can only come + // from the row core is about to depart — which is why the sync reads the ROWS + // before the upsert rather than just the keys. + assert.deepEqual(kinds(), ['core.member.left']) + assert.deepEqual(summaries(), ['Brenna left']) +}) + +test('an INCOMPLETE answer announces no departures, because it removed none', async () => { + provide(withMembers([member('0x1'), member('0x2')])) + await teamSync.reconcileNow('setup') + + await resync({ + getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }), + getTeamMembers: async () => ({ ok: true, complete: false, members: [member('0x1')] }), + getTeamLeaders: async () => ({ ok: true, leaders: [] }), + }, 'partial') + + assert.equal(activeMembers(1).length, 2, 'nobody was removed') + assert.deepEqual(store.activity, [], 'so nobody is announced as leaving') +}) + +test('promotion and demotion are announced; unchanged leadership is not', async () => { + provide(withMembers([member('0x1'), member('0x2')], ['0x1'])) + await teamSync.reconcileNow('setup') + + await resync(withMembers([member('0x1'), member('0x2')], ['0x2']), 'test') + + assert.deepEqual(kinds(), ['core.leader.changed', 'core.leader.changed']) + assert.deepEqual(summaries().sort(), ['0x1 stepped down as a leader', '0x2 became a leader']) +}) + +test('a member who joins already a leader is announced once, as joining', async () => { + provide(withMembers([member('0x1')], ['0x1'])) + await teamSync.reconcileNow('setup') + + await resync(withMembers([member('0x1'), member('0x2')], ['0x1', '0x2']), 'test') + + assert.deepEqual(kinds(), ['core.member.joined'], 'never a non-leader here to be promoted from') +}) + +test('a departing leader is announced as leaving, not as stepping down', async () => { + provide(withMembers([member('0x1'), member('0x2')], ['0x1', '0x2'])) + await teamSync.reconcileNow('setup') + + await resync(withMembers([member('0x1')], ['0x1']), 'test') + + assert.deepEqual(kinds(), ['core.member.left'], 'one event, not two') +}) + +test('a refused leadership answer announces nothing — it demoted nobody', async () => { + provide(withMembers([member('0x1')], ['0x1'])) + await teamSync.reconcileNow('setup') + + await resync({ + getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }), + getTeamMembers: async () => ({ ok: true, members: [member('0x1')] }), + getTeamLeaders: async () => ({ ok: false, reason: 'unavailable' }), + }, 'test') + + assert.deepEqual(store.activity, []) + assert.equal(activeMembers(1)[0].is_leader, 1, 'and left the stored value alone') +}) + +test('a rename is announced on the successor, naming the old name', async () => { + provide({ + getTeams: async () => ({ ok: true, teams: [team('g1', 'The Silver Hand')] }), + getTeamMembers: async () => ({ ok: true, members: [] }), + getTeamLeaders: async () => ({ ok: true, leaders: [] }), + }) + await teamSync.reconcileNow('setup') + + await resync({ + getTeams: async () => ({ ok: true, teams: [team('g1', 'The Golden Hand')] }), + getTeamMembers: async () => ({ ok: true, members: [] }), + getTeamLeaders: async () => ({ ok: true, leaders: [] }), + }, 'rename') + + const renames = store.activity.filter((a) => a.kind === 'core.team.renamed') + assert.equal(renames.length, 1) + assert.equal(renames[0].summary, 'Renamed from The Silver Hand') + // The SUCCESSOR row, not the archived one: the archived row is a read-only + // record of what came before, and the reader is looking at the live page. + const successor = store.teams.find((t) => t.status === 'active') + assert.equal(renames[0].teamId, successor.id) +}) + +test('a member with no display name is announced without leaking the member key', async () => { + provide(withMembers([member('0x1')])) + await teamSync.reconcileNow('setup') + + await resync(withMembers([member('0x1'), member('0x2', { displayName: null })]), 'test') + + assert.deepEqual(summaries(), ['A member joined']) +}) + +test('a feed write that fails never fails the sync', async () => { + patch(activity, 'logCore', async () => { throw new Error('table gone') }) + provide(withMembers([member('0x1')])) + await teamSync.reconcileNow('setup') + + const result = await resync(withMembers([member('0x1'), member('0x2')]), 'test') + + assert.equal(result.ok, true) + assert.equal(activeMembers(1).length, 2, 'the roster is the source of truth and it applied') +}) -- 2.49.1 From 03631d7d40cf54895f1c8fb5bf6085352dda13c9 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 17 Aug 2026 20:15:36 -0500 Subject: [PATCH 08/35] feat(teams): the roster's audience projection, and optionalAuth to resolve it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TEAMS.md §3.3, as the eighth member of MODULE_API 1.6.0 — amended in place per the org lead, on the rule Protocol 4 was given in phase 2: a contract owes a bump only once it has landed on `main`. Two questions meet on the roster and they belong to different owners. WHICH ROWS a viewer may see is the module's, because the audience rungs and their configuration live there and core does not know what a rung is. WHAT A ROW LOOKS LIKE stays core's. So `projectRoster` answers with member KEYS, not rows. §3.3 said rows, and rows would let a module widen what is published — handing back a `userId` core had withheld — leaving core's field guarantee resting on every module's good behaviour. Core asks which rows and re-normalises the answer through its own public shape, so a module can narrow and cannot widen. "The module declines" needed splitting before it could be implemented. No module at all and a module whose rungs could not be consulted are opposite situations: the first withholds nothing and must serve the roster whole, the second must serve none of it. The refusal carries `projects`, and only `projects: true` fails closed. Without the split, bare core serves an empty roster on every Team page. This is also the first public route whose CONTENT depends on identity, which needed a middleware core did not have. `attachSession` only decodes a token, so a banned account, a password change or a logout would have kept working against the private half of a feed until the JWT expired. `optionalAuth` runs requireAuth's full database re-validation and, on any failure, continues ANONYMOUSLY rather than rejecting — a caller whose session is no longer good sees the public view, which is what they are entitled to. `GET /public/teams/:slug/activity` lands here for the same reason: §2.11's route table had no activity endpoint though §4.3 describes a filtered feed. Paged, with the visibility resolved from the session and never from a parameter. Co-Authored-By: Claude --- server/src/auth/session.middleware.js | 39 ++++++ server/src/model/teams/teamProvider.js | 57 ++++++++ server/src/model/teams/teams.model.js | 63 ++++++++- server/src/modules/registries.js | 21 ++- .../src/router/v1/public/teams.controller.js | 38 +++++- server/src/router/v1/public/teams.router.js | 25 +++- server/swagger/swagger.js | 41 ++++++ server/test/teamProvider.test.js | 87 +++++++++++++ server/test/teamRoster.test.js | 122 ++++++++++++++++++ 9 files changed, 486 insertions(+), 7 deletions(-) create mode 100644 server/test/teamRoster.test.js diff --git a/server/src/auth/session.middleware.js b/server/src/auth/session.middleware.js index 1171858..93ca03e 100644 --- a/server/src/auth/session.middleware.js +++ b/server/src/auth/session.middleware.js @@ -79,6 +79,44 @@ async function requireAuth(req, res, next) { } } +// Best-effort AUTHENTICATION, as opposed to attachSession's best-effort decode. +// +// For a PUBLIC route whose content — not merely its presentation — depends on who +// is asking. The Team activity feed is the first: `public` items go to everyone +// and `members` items only to members and forum-granted users (TEAMS.md §4.3), so +// an anonymous caller must be served, not rejected, and an authenticated one must +// be identified properly. +// +// "Properly" is why this is not attachSession. That one decodes the token and +// stops, which is right for reading back your own session but wrong here: a +// banned account, a password change, or a logout would all keep working against +// the private half of the feed until the JWT expired. This runs the same +// database re-validation requireAuth does — status, cutoff, revocation — and on +// any failure continues ANONYMOUSLY rather than 401ing. A caller whose session is +// no longer good sees the public feed, which is exactly what they are entitled to. +// +// A database error also degrades to anonymous. On a public route the safe +// direction is to serve less, and 500ing a page because a session lookup failed +// would take the whole Team page down for callers who never sent a token. +async function optionalAuth(req, res, next) { + const session = sessionService.validateSession(req) + if (!session) return next() + try { + const user = await users.getById(session.userId) + if (!user) return next() + if (user.status && user.status !== 'active') return next() + if (isBeforeCutoff(session, user.tokens_valid_after)) return next() + if (await sessionService.isSessionRevoked(session.sessionId)) return next() + + req.user = user + req.session = session + req.authMethod = session.authMethod + } catch (err) { + log.warn('optionalAuth: continuing anonymously', { message: err.message }) + } + return next() +} + // Gate middleware factory: allow only the listed roles. Assumes requireAuth ran // first so req.user is populated. Use for admin-only endpoints (users, site // mode, settings) so a lower-privilege editor cannot reach them. @@ -91,6 +129,7 @@ function requireRole(...roles) { module.exports = { attachSession, + optionalAuth, requireAuth, requireRole, } diff --git a/server/src/model/teams/teamProvider.js b/server/src/model/teams/teamProvider.js index 945f983..b2253b7 100644 --- a/server/src/model/teams/teamProvider.js +++ b/server/src/model/teams/teamProvider.js @@ -163,10 +163,66 @@ function normaliseLeaders(answer) { return { ok: true, leaders } } +/** + * `{ ok, members: [memberKey] }` — WHICH rows the module permits this viewer. + * + * Deliberately a set of keys rather than a set of rows. Core already holds the + * rows and knows their public shape; asking the module for rows back would let a + * module widen what is published — re-adding a `userId` or a `memberKey` that + * §3.2 says is never published — and core's field guarantee would then rest on + * every module's good behaviour rather than on core. So the module answers the + * question it actually owns (who may be seen at this rung) and core keeps the + * question it owns (what a member row looks like in public). + */ +function normaliseVisibleKeys(answer) { + if (!Array.isArray(answer.members)) return fail('projectRoster() answered ok with no members array') + const keys = [] + for (const raw of answer.members) { + const key = str(raw) + if (!key) return fail('a projectRoster() entry is not a member key') + if (!keys.includes(key)) keys.push(key) + } + return { ok: true, members: keys } +} + const getTeams = () => call('getTeams', normaliseTeams) const getTeamMembers = (externalId) => call('getTeamMembers', normaliseMembers, externalId) const getTeamLeaders = (externalId) => call('getTeamLeaders', normaliseLeaders, externalId) +/** + * Ask the module which roster rows this viewer may see (§3.3). + * + * The per-audience projection is the module's because the visibility framework + * and its rung configuration are module-owned (§10.5) — core does not know what a + * rung is. Core supplies the roster and a description of the viewer; the module + * returns the member keys it permits. + * + * **"No audience model" and "could not answer" are different, and the caller must + * be able to tell them apart** — so the refusal carries `projects`. + * + * `projects: false` — no provider is registered, or the registered one does not + * implement `projectRoster`. There is no rung system to consult and nothing + * is being withheld; the roster is served at core's public shape. This is why + * the member is OPTIONAL: bare core, and a module with no audience model of + * its own, both render exactly the page core writes. + * + * `projects: true` — the module HAS an audience model and core could not reach + * it (refused, threw, timed out, answered malformed). Here the caller must + * fail CLOSED, because "leave it alone" would mean publishing the very rows + * the rungs exist to withhold. This is the one place in the Team subsystem + * where unavailability is not staleness: everywhere else a refused call + * leaves data alone, and doing that to a *visibility* question is a leak. + */ +async function projectRoster(externalId, members, viewer) { + const provider = registries.registeredTeamProvider() + if (!provider) return { ...fail('no team provider is registered'), projects: false } + if (typeof provider.projectRoster !== 'function') { + return { ...fail('provider does not project rosters'), projects: false } + } + const answer = await call('projectRoster', normaliseVisibleKeys, externalId, members, viewer) + return answer.ok ? answer : { ...answer, projects: true } +} + /** Which module is authoritative, or null. The reconciler keys sync state on it. */ const providerModuleId = () => { const provider = registries.registeredTeamProvider() @@ -177,6 +233,7 @@ module.exports = { getTeams, getTeamMembers, getTeamLeaders, + projectRoster, providerModuleId, CALL_TIMEOUT_MS, } diff --git a/server/src/model/teams/teams.model.js b/server/src/model/teams/teams.model.js index 7f09dae..c25eff2 100644 --- a/server/src/model/teams/teams.model.js +++ b/server/src/model/teams/teams.model.js @@ -156,6 +156,16 @@ async function listPublic({ limit = 50, offset = 0 } = {}) { teams: visible.slice(offset, offset + limit).map(publicTeam), total: visible.length, ...sync, + // What the `teams` nav feature flag resolves from (§3.5). True if a provider + // is registered OR any Team exists — the second half matters because Team + // rows outlive the module that filled them, and hiding the nav entry the + // moment a module is uninstalled would make every existing Team page + // unreachable from the site while still answering by URL. + // + // False only when there is nothing and no prospect of anything, which is + // exactly the bare-core case the flag exists for: a link to a permanently + // empty page is worse than no link. + enabled: Boolean(sync.configured) || visible.length > 0, } } @@ -174,6 +184,19 @@ async function getPublic(slug) { const successor = row.succeeded_by ? await teamsDb.findById(row.succeeded_by) : null return { ...publicTeam(row), + // The three props the `team.overview` extension slot is declared with + // (§3.4). A module's slot component runs in the browser and has to know + // WHICH Team it is looking at, in its own vocabulary — `slug` is core's name + // for it and resolves nothing on the module's side. + // + // On this route only, deliberately: the index has no slot and would + // otherwise publish a module-internal identifier per row for nothing. None + // of the three names a person — they are a core row id, a game-side group + // id and a module name, and the identifiers §3.2 withholds (member keys, + // site account ids) are not among them. + id: row.id, + externalId: row.external_id, + moduleId: row.module_id, ...sync, successor: successor && !successor.hidden ? { slug: successor.slug, name: successor.display_name_override || successor.name } @@ -181,14 +204,50 @@ async function getPublic(slug) { } } -async function rosterPublic(slug) { +/** + * A Team's roster, projected for the caller's audience rung (§3.3). + * + * The ROW filter is the module's: it owns the visibility framework and its + * configuration (§10.5), and core does not know what a rung is. The FIELD shape + * stays core's — every row that survives goes through `publicMember`, which + * withholds the member key and the user id whatever the module answers. So a + * module can narrow what is published and cannot widen it, and core's "neither is + * published" guarantee does not rest on every module's good behaviour. + * + * **A module that HAS a rung system and cannot answer withholds the roster.** That + * is the one Team call where a refusal is not staleness: leaving a visibility + * answer "alone" would publish the very rows the rungs exist to withhold. A + * deployment with no module, or one whose module does not project at all, is a + * different case entirely — nothing is being withheld there, so the roster is + * served whole at core's public shape (`projects: false`). + */ +async function rosterPublic(slug, viewer = null) { const row = await teamsDb.findBySlug(slug) if (!row || row.hidden) return null const [members, sync] = await Promise.all([ access.rosterWithOverrides(row.id), syncStatus(), ]) - return { members: members.map(publicMember), ...sync, rosterSyncedAt: row.roster_synced_at } + + // The module gets the rows as it supplied them — this is its own data coming + // home — plus who is asking, which is all a rung decision needs. + const answer = await teamProvider.projectRoster(row.external_id, members, viewer) + let visible + if (answer.ok) visible = members.filter((m) => answer.members.includes(m.member_key)) + else if (answer.projects) visible = [] // fail closed: it has rungs and we could not ask + else visible = members // nothing to fail closed ABOUT + + return { + members: visible.map(publicMember), + ...sync, + rosterSyncedAt: row.roster_synced_at, + // Stated rather than implied. An empty roster has three quite different + // causes — a Team with no members, a rung that shows none, and a module that + // could not be asked — and a page that cannot tell them apart will report the + // last one as the first. + projected: answer.ok, + ...(answer.ok || !answer.projects ? {} : { projectionUnavailable: true }), + } } // ── Player ───────────────────────────────────────────────────────────────── diff --git a/server/src/modules/registries.js b/server/src/modules/registries.js index 7165f8e..e0fa24c 100644 --- a/server/src/modules/registries.js +++ b/server/src/modules/registries.js @@ -243,11 +243,22 @@ 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 +// Three methods are REQUIRED, with no optional half. A provider that could list +// Teams but not their members would leave core holding Teams it can never // populate, and the reconciler has no sensible behaviour for that — it is not the // same as a call that fails, which is staleness and already handled (§2.4). A // module unable to answer one of the three answers `{ ok: false }` at call time. +// +// `projectRoster` is the fourth and is OPTIONAL (TEAMS.md §3.3): it expresses an +// audience model, and a module with no rung system of its own has no opinion to +// express. Omitting it means core serves rosters at its own public shape; +// implementing it means core fails CLOSED when the call cannot be made, so this +// is a member to add deliberately rather than by habit. +// +// The copy is explicit rather than a spread: this object is what core calls, so +// anything not named here is not part of the contract and must not survive +// registration. A method that silently rode along would look implemented from the +// module's side and be invisible from core's. function checkTeamProviderShape(entry) { const provider = entry || {} const out = {} @@ -257,6 +268,12 @@ function checkTeamProviderShape(entry) { } out[name] = provider[name] } + if (provider.projectRoster !== undefined) { + if (typeof provider.projectRoster !== 'function') { + throw new Error('registerTeamProvider: projectRoster must be a function if present') + } + out.projectRoster = provider.projectRoster + } return out } diff --git a/server/src/router/v1/public/teams.controller.js b/server/src/router/v1/public/teams.controller.js index 701cc9b..e92da83 100644 --- a/server/src/router/v1/public/teams.controller.js +++ b/server/src/router/v1/public/teams.controller.js @@ -5,6 +5,7 @@ // marked stale, because that is what the projection is for. const teams = require('../../../model/teams/teams.model') +const teamActivity = require('../../../model/teams/teamActivity.model') const log = require('../../../utils/logger')('teams') @@ -35,9 +36,18 @@ async function getTeam(req, res) { } } +/** + * The roster, projected for whoever is asking (§3.3). + * + * The viewer is described to the module rather than handed over: it gets the + * caller's id and role, which is what a rung decision turns on, and not the user + * row — a module has `ctx.users.getById` if it needs more, and passing the whole + * record here would make every column of `users` part of this contract. + */ async function getRoster(req, res) { try { - const roster = await teams.rosterPublic(req.params.slug) + const viewer = req.user ? { userId: req.user.id, role: req.user.role } : null + const roster = await teams.rosterPublic(req.params.slug, viewer) if (!roster) return res.status(404).json({ message: 'Team not found' }) return res.json(roster) } catch (err) { @@ -45,4 +55,28 @@ async function getRoster(req, res) { } } -module.exports = { listTeams, getTeam, getRoster } +/** + * A Team's activity feed (§4.3). + * + * The only handler in this tier that reads `req.user`, and it reads nothing else + * from the caller about what they may see: `limit` and `offset` are page + * controls, and the visibility filter is resolved from the session alone. A + * request parameter naming its own visibility is the bug the ENUM exists to + * prevent, so there is deliberately no way to ask for one. + * + * The cap is 100 rather than the index's 200 — every row carries a summary and an + * opaque payload, so a page of these is much larger than a page of Teams. + */ +async function getActivity(req, res) { + try { + const limit = Math.min(Math.max(Number.parseInt(req.query.limit, 10) || 50, 1), 100) + const offset = Math.max(Number.parseInt(req.query.offset, 10) || 0, 0) + const feed = await teamActivity.feedFor(req.params.slug, req.user ? req.user.id : null, { limit, offset }) + if (!feed) return res.status(404).json({ message: 'Team not found' }) + return res.json(feed) + } catch (err) { + return fail(res, err, 'activity') + } +} + +module.exports = { listTeams, getTeam, getRoster, getActivity } diff --git a/server/src/router/v1/public/teams.router.js b/server/src/router/v1/public/teams.router.js index 3b17d25..71f12ef 100644 --- a/server/src/router/v1/public/teams.router.js +++ b/server/src/router/v1/public/teams.router.js @@ -12,6 +12,7 @@ const express = require('express') const ctrl = require('./teams.controller') const siteMode = require('../../../middleware/siteMode') +const { optionalAuth } = require('../../../auth/session.middleware') const teamsRouter = express.Router() @@ -43,12 +44,34 @@ 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.description = 'In-game display names only. A member key is a game-internal identifier and a user id names a site account; neither is published, whatever the module’s projection answers. `linked` answers whether a character has an account behind it without saying which. WHICH rows appear is the module’s audience projection; sending a session is optional and may widen it.' // #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' } + // #swagger.security = [{}, { "cookieAuth": [] }, { "bearerAuth": [] }] /* #swagger.responses[200] = { description: 'The roster, with sync freshness', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicTeamRoster" } } } } */ /* #swagger.responses[404] = { description: 'No such Team, or it is hidden', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ siteMode, + optionalAuth, ctrl.getRoster, ) +// The one route in this tier that reads the caller's identity. `optionalAuth` +// serves anonymous callers rather than rejecting them, and identifies an +// authenticated one properly enough that a banned or logged-out account drops +// back to the public half of the feed at once (TEAMS.md §4.3). +teamsRouter.get( + '/:slug/activity', + // #swagger.tags = ['Public · Teams'] + // #swagger.summary = 'A Team’s activity feed, filtered to what the caller may see' + // #swagger.description = 'Items are `public` or `members`. Anyone who can see the Team gets the public ones; members and forum-granted users also get the members-only ones, and the response says which via `scope` so a client can render "some items are hidden" rather than presenting a filtered feed as the whole one. Sending a session is optional.' + // #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' } + // #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Page size, max 100 (default 50).' } + // #swagger.parameters['offset'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Rows to skip (default 0).' } + // #swagger.security = [{}, { "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'One page of the feed', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicTeamActivity" } } } } */ + /* #swagger.responses[404] = { description: 'No such Team, or it is hidden from this caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + siteMode, + optionalAuth, + ctrl.getActivity, +) + module.exports = teamsRouter diff --git a/server/swagger/swagger.js b/server/swagger/swagger.js index 03ebf40..47731ee 100644 --- a/server/swagger/swagger.js +++ b/server/swagger/swagger.js @@ -979,6 +979,12 @@ const doc = { properties: { teams: { type: 'array', items: { $ref: '#/components/schemas/PublicTeam' } }, total: { type: 'integer', example: 12 }, + enabled: { + type: 'boolean', + description: + 'Whether this deployment has Teams at all — a provider is registered, or Teams exist from one that since went away. The `teams` nav feature flag resolves from this; false means bare core, where a Teams link would lead to a permanently empty page.', + example: true, + }, }, }, PublicTeamMember: { @@ -999,6 +1005,41 @@ const doc = { properties: { members: { type: 'array', items: { $ref: '#/components/schemas/PublicTeamMember' } }, rosterSyncedAt: { type: 'string', format: 'date-time', nullable: true }, + projected: { + type: 'boolean', + description: + 'Whether the module applied its own audience projection to this roster. False means the module declined or does not project, and the roster was served at core’s public shape — never the full one.', + example: true, + }, + }, + }, + PublicTeamActivityItem: { + type: 'object', + description: + '`summary` is already-rendered text supplied by whoever pushed the item; core never composes one. `kind` and `payload` are opaque to core — only the module’s `team.overview` slot renders anything richer than the text.', + properties: { + id: { type: 'integer', example: 4821 }, + source: { type: 'string', description: '`core` or a module id.', example: 'uo' }, + kind: { type: 'string', example: 'uo.champion.completed' }, + summary: { type: 'string', example: 'Completed Champion Neira' }, + visibility: { type: 'string', enum: ['public', 'members'] }, + occurredAt: { type: 'string', format: 'date-time' }, + payload: { type: 'object', nullable: true, additionalProperties: true }, + }, + }, + PublicTeamActivity: { + type: 'object', + properties: { + items: { type: 'array', items: { $ref: '#/components/schemas/PublicTeamActivityItem' } }, + total: { type: 'integer', description: 'Matching rows for THIS caller’s visibility, so paging is honest.', example: 137 }, + limit: { type: 'integer', example: 50 }, + offset: { type: 'integer', example: 0 }, + scope: { + type: 'string', + enum: ['public', 'members'], + description: + 'Which visibilities this caller received. `public` means members-only items were withheld — render that fact rather than presenting a filtered feed as the whole one.', + }, }, }, PlayerTeamList: { diff --git a/server/test/teamProvider.test.js b/server/test/teamProvider.test.js index 75bdd5b..f160d6b 100644 --- a/server/test/teamProvider.test.js +++ b/server/test/teamProvider.test.js @@ -287,3 +287,90 @@ test('a hung call does not hold the process open until its deadline', async () = test('the budget is the documented ten seconds', () => { assert.equal(teamProvider.CALL_TIMEOUT_MS, 10_000) }) + +// ── projectRoster: the optional fourth member (§3.3) ─────────────────────── +// +// The one Team call where a refusal must NOT be treated as staleness. Every test +// below exists because the obvious implementation — reuse `call()` and serve the +// roster when it fails — silently publishes the rows the rungs exist to withhold. + +const rows = [{ member_key: '0x1' }, { member_key: '0x2' }] + +test('projectRoster is optional: a provider without it registers fine', () => { + const api = registries.stage('uo') + assert.doesNotThrow(() => api.registerTeamProvider(ok())) +}) + +test('a non-function projectRoster is rejected at registration, not at call time', () => { + const api = registries.stage('uo') + assert.throws( + () => api.registerTeamProvider({ ...ok(), projectRoster: 'yes please' }), + /projectRoster must be a function/, + ) +}) + +test('an unregistered method cannot ride along into the provider core calls', () => { + register('uo', { ...ok(), somethingElse: async () => 'hi' }) + assert.equal(registries.registeredTeamProvider().somethingElse, undefined) +}) + +test('no provider at all is projects:false — nothing is being withheld', async () => { + const answer = await teamProvider.projectRoster('g1', rows, null) + assert.equal(answer.ok, false) + assert.equal(answer.projects, false) +}) + +test('a provider that does not project is projects:false, not a failure to fear', async () => { + register('uo', ok()) + const answer = await teamProvider.projectRoster('g1', rows, null) + assert.equal(answer.ok, false) + assert.equal(answer.projects, false) +}) + +test('a provider that HAS projectRoster and refuses is projects:true — the caller must fail closed', async () => { + register('uo', { ...ok(), projectRoster: async () => ({ ok: false, reason: 'atlas not loaded' }) }) + const answer = await teamProvider.projectRoster('g1', rows, null) + assert.equal(answer.ok, false) + assert.equal(answer.projects, true) + assert.equal(answer.reason, 'atlas not loaded') +}) + +test('a projectRoster that throws is projects:true as well — a bug is not permission', async () => { + register('uo', { ...ok(), projectRoster: async () => { throw new Error('boom') } }) + const answer = await teamProvider.projectRoster('g1', rows, null) + assert.equal(answer.projects, true) +}) + +test('the module receives the rows and the viewer, and answers with member keys', async () => { + let seen + register('uo', { + ...ok(), + projectRoster: async (externalId, members, viewer) => { + seen = { externalId, members, viewer } + return { ok: true, members: ['0x2'] } + }, + }) + const answer = await teamProvider.projectRoster('g1', rows, { userId: 7, role: 'player' }) + assert.deepEqual(seen.members, rows) + assert.deepEqual(seen.viewer, { userId: 7, role: 'player' }) + assert.equal(seen.externalId, 'g1') + assert.deepEqual(answer.members, ['0x2']) +}) + +test('a malformed key list is a refusal, so the caller fails closed rather than serving garbage', async () => { + for (const bad of [{ ok: true }, { ok: true, members: ['ok', ''] }, { ok: true, members: 'all' }]) { + // eslint-disable-next-line no-await-in-loop + register('uo', { ...ok(), projectRoster: async () => bad }) + // eslint-disable-next-line no-await-in-loop + const answer = await teamProvider.projectRoster('g1', rows, null) + assert.equal(answer.ok, false, JSON.stringify(bad)) + assert.equal(answer.projects, true) + registries._reset() + } +}) + +test('duplicate keys are collapsed', async () => { + register('uo', { ...ok(), projectRoster: async () => ({ ok: true, members: ['0x1', '0x1', '0x2'] }) }) + const answer = await teamProvider.projectRoster('g1', rows, null) + assert.deepEqual(answer.members, ['0x1', '0x2']) +}) diff --git a/server/test/teamRoster.test.js b/server/test/teamRoster.test.js new file mode 100644 index 0000000..a0b45ba --- /dev/null +++ b/server/test/teamRoster.test.js @@ -0,0 +1,122 @@ +// The roster read and its audience projection (docs/website/TEAMS.md §3.2, §3.3). +// +// Two questions meet here and the file exists to keep them apart: +// +// WHICH ROWS is the module's — it owns the visibility framework and its rung +// configuration, and core does not know what a rung is. +// WHAT A ROW is core's — the member key and the user id are never published, +// LOOKS LIKE whatever the module answers. +// +// The dangerous simplification is to let the module return rows instead of keys: +// core's field guarantee would then rest on every module's good behaviour rather +// than on core, and one module re-adding a `userId` would publish site accounts +// against in-game characters on a public page. +const { test, beforeEach, afterEach } = require('node:test') +const assert = require('node:assert/strict') + +const teamsDb = require('../src/model/teams/teams.db') +const access = require('../src/model/teams/teamAccess.model') +const teamProvider = require('../src/model/teams/teamProvider') +const teamSync = require('../src/model/teams/teamSync.model') +const teams = require('../src/model/teams/teams.model') + +const saved = new Map() + +function patch(mod, name, fn) { + if (!saved.has(mod)) saved.set(mod, new Map()) + if (!saved.get(mod).has(name)) saved.get(mod).set(name, mod[name]) + mod[name] = fn +} + +function restore() { + for (const [mod, names] of saved) for (const [name, fn] of names) mod[name] = fn + saved.clear() +} + +const ROWS = [ + { member_key: '0x1', display_name: 'Aldric', user_id: 7, is_leader: 1, rank_label: 'Leader', online: 1 }, + { member_key: '0x2', display_name: 'Brenna', user_id: null, is_leader: 0, rank_label: null, online: 0 }, + { member_key: '0x3', display_name: 'Cadfael', user_id: 9, is_leader: 0, rank_label: null, online: 0 }, +] + +beforeEach(() => { + patch(teamsDb, 'findBySlug', async (slug) => + (slug === 'the-guild' + ? { id: 1, external_id: 'g1', slug, hidden: 0, status: 'active', roster_synced_at: null } + : undefined)) + patch(access, 'rosterWithOverrides', async () => ROWS.map((r) => ({ ...r }))) + // syncStatus() reads sync state and the poll interval; neither is what this + // file is about, and both would otherwise reach the pool. + patch(teamProvider, 'providerModuleId', () => null) + patch(teamSync, 'intervalSeconds', async () => 900) +}) +afterEach(restore) + +test('with no module projecting, the whole roster is served at core\'s public shape', async () => { + patch(teamProvider, 'projectRoster', async () => ({ ok: false, projects: false, reason: 'no provider' })) + const roster = await teams.rosterPublic('the-guild', null) + assert.equal(roster.members.length, 3) + assert.equal(roster.projected, false) + assert.equal(roster.projectionUnavailable, undefined, 'nothing was withheld, so nothing to report') +}) + +test('the module chooses which rows a viewer sees', async () => { + patch(teamProvider, 'projectRoster', async () => ({ ok: true, members: ['0x2'] })) + const roster = await teams.rosterPublic('the-guild', null) + assert.deepEqual(roster.members.map((m) => m.displayName), ['Brenna']) + assert.equal(roster.projected, true) +}) + +test('a module that projects but cannot answer withholds the roster — it does not serve it', async () => { + // The whole point. "Leave it alone" is right for a roster SYNC and wrong for a + // visibility question: it would publish exactly what the rungs withhold. + patch(teamProvider, 'projectRoster', async () => ({ ok: false, projects: true, reason: 'sidecar down' })) + const roster = await teams.rosterPublic('the-guild', null) + assert.deepEqual(roster.members, []) + assert.equal(roster.projected, false) + assert.equal(roster.projectionUnavailable, true, 'an empty roster must be distinguishable from a silent one') +}) + +test('the module cannot widen the published fields, only narrow the rows', async () => { + // A module answering with keys it was given still yields core's shape. There is + // no answer it can give that puts a member key or a user id on a public page. + patch(teamProvider, 'projectRoster', async () => ({ ok: true, members: ['0x1', '0x2', '0x3'] })) + const roster = await teams.rosterPublic('the-guild', null) + for (const member of roster.members) { + assert.deepEqual( + Object.keys(member).sort(), + ['displayName', 'isLeader', 'linked', 'online', 'rankLabel'], + 'the public member shape is core\'s and is closed', + ) + } + assert.deepEqual(roster.members.map((m) => m.linked), [true, false, true]) +}) + +test('a key the module invents matches nothing rather than adding a row', async () => { + patch(teamProvider, 'projectRoster', async () => ({ ok: true, members: ['0x1', '0xNOPE'] })) + const roster = await teams.rosterPublic('the-guild', null) + assert.equal(roster.members.length, 1) +}) + +test('the viewer is described to the module, not handed over', async () => { + let seen + patch(teamProvider, 'projectRoster', async (externalId, members, viewer) => { + seen = viewer + return { ok: true, members: members.map((m) => m.member_key) } + }) + await teams.rosterPublic('the-guild', { userId: 7, role: 'player' }) + assert.deepEqual(seen, { userId: 7, role: 'player' }) +}) + +test('an unknown slug is not found, and the module is never consulted about it', async () => { + let called = false + patch(teamProvider, 'projectRoster', async () => { called = true; return { ok: true, members: [] } }) + assert.equal(await teams.rosterPublic('no-such-team', null), null) + assert.equal(called, false) +}) + +test('a hidden team\'s roster does not answer publicly at all', async () => { + patch(teamsDb, 'findBySlug', async () => ({ id: 1, external_id: 'g1', slug: 'x', hidden: 1, status: 'active' })) + patch(teamProvider, 'projectRoster', async () => ({ ok: true, members: ['0x1'] })) + assert.equal(await teams.rosterPublic('x', null), null) +}) -- 2.49.1 From 8f4aff69463f67087f0a783b1c57f55d99a4b60f Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 17 Aug 2026 20:15:54 -0500 Subject: [PATCH 09/35] feat(teams): the public Team pages, the two slots and the nav flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TEAMS.md §3.1–§3.5. Four core pages — the index, a Team's overview, its full roster and the player portal's "My Teams" — plus the two extension slots a module adds to them, and the nav rows that lead there. These are CORE routes, not module ones. A Team is a core platform entity that a module merely populates, so the whole experience renders on bare core; a module adds to these pages rather than supplying them. `team.member.row` is declared with `{ displayName, isLeader, linked }` and not §3.4's `{ memberKey, userId, displayName }`. The two documents contradict each other and §3.2 is the one that is a security rule: a slot component runs in the browser, so those props can only reach it by publishing a game-internal identifier and a site account id in every public roster response, for every visitor, module installed or not. Recorded as an amendment. The presentation logic is split into lib/teams.js with its own tests, following lib/teamAdmin.js, because these pages have to state differences that read as bugs unless they are worded deliberately: - "37 members · 21 linked" — the gap is information (a character with no site account behind it), and the header says what each number IS rather than showing both and hoping; - an empty roster has three unrelated causes — nobody in the Team, a rung that shows nobody, and a module that could not be asked — and reporting the last as the first is a statement about the game that happens to be false; - a stale projection says how old it is rather than presenting itself as current. `teams` is the first CORE nav row to carry a `feature` since the shard rows left with the module cutover, and it brings core's own feature provider back with it. It gates on whether this deployment has Teams AT ALL, not on who is looking — Team pages are public and the server gates them. It fails open, so an unknown answer shows the link: a Teams link leading somewhere empty is a far cheaper mistake than a Team page nobody can find. Co-Authored-By: Claude --- client/src/App.jsx | 16 ++ client/src/api/client.js | 23 +++ client/src/components/SiteHeader.jsx | 5 + client/src/lib/teams.js | 151 ++++++++++++++++ client/src/main.jsx | 23 ++- client/src/modules/coreFeatures.js | 56 ++++++ .../src/routes/player/PlayerPortalLayout.jsx | 8 + client/src/routes/player/PlayerTeams.jsx | 67 ++++++++ client/src/routes/public/Team.jsx | 126 ++++++++++++++ client/src/routes/public/TeamRoster.jsx | 120 +++++++++++++ client/src/routes/public/Teams.jsx | 107 ++++++++++++ client/test/teams.test.js | 162 ++++++++++++++++++ 12 files changed, 861 insertions(+), 3 deletions(-) create mode 100644 client/src/lib/teams.js create mode 100644 client/src/modules/coreFeatures.js create mode 100644 client/src/routes/player/PlayerTeams.jsx create mode 100644 client/src/routes/public/Team.jsx create mode 100644 client/src/routes/public/TeamRoster.jsx create mode 100644 client/src/routes/public/Teams.jsx create mode 100644 client/test/teams.test.js diff --git a/client/src/App.jsx b/client/src/App.jsx index 1750361..6353cc5 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -18,6 +18,9 @@ import Newsletter from './routes/public/Newsletter.jsx' import NewsletterIssue from './routes/public/NewsletterIssue.jsx' import About from './routes/public/About.jsx' import Status from './routes/public/Status.jsx' +import Teams from './routes/public/Teams.jsx' +import Team from './routes/public/Team.jsx' +import TeamRoster from './routes/public/TeamRoster.jsx' import Wiki from './routes/wiki/Wiki.jsx' import WikiArticle from './routes/wiki/WikiArticle.jsx' import CmsPage from './routes/public/CmsPage.jsx' @@ -57,6 +60,7 @@ import AcceptInvite from './routes/player/AcceptInvite.jsx' import PlayerPortalLayout, { PlayerIndex } from './routes/player/PlayerPortalLayout.jsx' import PlayerAccount from './routes/player/PlayerAccount.jsx' import PlayerAppeals from './routes/player/PlayerAppeals.jsx' +import PlayerTeams from './routes/player/PlayerTeams.jsx' export default function App() { return ( @@ -93,6 +97,13 @@ export default function App() { } /> } /> } /> + {/* Teams (TEAMS.md §3.1). Core routes, not module ones: a Team is a + core platform entity that a module merely populates, so these + render on bare core too. `/teams` is declared before `/:slug` + below for the same reason every named route is. */} + } /> + } /> + } /> {/* Installed modules' public pages, namespaced `//…` — the registry prefixes the segment, so a module cannot spell its way out of it (docs/website/MODULE_API.md §3.3). Declared before the @@ -216,6 +227,11 @@ export default function App() { } /> } /> } /> + {/* Core's own page under the /player prefix, unlike the module + pages below it. Open to any authenticated account, not role + 'player': staff are a superset of players and a moderator is in + guilds too — RequirePlayer above already draws that line. */} + } /> {/* Installed modules' player-portal pages, at /player//…. This group's own routes are absolute (its layout route has no path), so the prefix is written here rather than inherited — the one diff --git a/client/src/api/client.js b/client/src/api/client.js index 64359c4..bd19f4d 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -133,6 +133,29 @@ export const api = { return req(`/public/wiki${withQs(s)}`) }, wikiCategories: () => req('/public/wiki/categories'), + + // ----- Teams (TEAMS.md §2.11, §3.1) ----- + // + // Public reads, but three of them behave differently for a signed-in caller and + // the session rides along on the cookie the shared `req` already sends: the + // roster may widen with the module's audience projection, and the activity feed + // adds members-only items. None of them REQUIRES a session. + teams: (opts = {}) => { + const qs = new URLSearchParams() + if (opts.limit != null) qs.set('limit', String(opts.limit)) + if (opts.offset != null) qs.set('offset', String(opts.offset)) + return req(`/public/teams${withQs(qs.toString())}`) + }, + team: (slug) => req(`/public/teams/${encodeURIComponent(slug)}`), + teamRoster: (slug) => req(`/public/teams/${encodeURIComponent(slug)}/members`), + teamActivity: (slug, opts = {}) => { + const qs = new URLSearchParams() + if (opts.limit != null) qs.set('limit', String(opts.limit)) + if (opts.offset != null) qs.set('offset', String(opts.offset)) + return req(`/public/teams/${encodeURIComponent(slug)}/activity${withQs(qs.toString())}`) + }, + myTeams: () => req('/player/teams'), + myTeamAccess: (slug) => req(`/player/teams/${encodeURIComponent(slug)}/access`), wikiTags: () => req('/public/wiki/tags'), wikiPage: (slug) => req(`/public/wiki/${slug}`), // CMS pages (block-based). Published-only for the public; a draft-preview link diff --git a/client/src/components/SiteHeader.jsx b/client/src/components/SiteHeader.jsx index 84706f0..064536d 100644 --- a/client/src/components/SiteHeader.jsx +++ b/client/src/components/SiteHeader.jsx @@ -28,6 +28,11 @@ import { useFeatureGate } from '../modules/features.jsx' export const NAV = [ { label: 'Home', to: '/', end: true }, { label: 'News', to: '/site/news' }, + // The first CORE row to carry a `feature` since the shard rows left in slice 3 + // (TEAMS.md §3.5). It is answered by core's own provider (main.jsx) and gates + // on whether this deployment has Teams at all, not on who is looking — Team + // pages are public. Fails open, so an unknown answer shows the link. + { label: 'Teams', to: '/teams', feature: 'teams' }, { label: 'Screenshots', to: '/site/screenshots' }, { label: 'Five on Friday', to: '/site/five-on-friday' }, { label: 'Newsletter', to: '/site/newsletter' }, diff --git a/client/src/lib/teams.js b/client/src/lib/teams.js new file mode 100644 index 0000000..e73a208 --- /dev/null +++ b/client/src/lib/teams.js @@ -0,0 +1,151 @@ +// What the public Team pages SAY, separated from how they render +// (docs/website/TEAMS.md §3.2, §3.3, §4.3). +// +// Plain JS with tests, following lib/teamAdmin.js. It is worth splitting here for +// the same reason it was there: these pages have to state differences that look +// like bugs unless they are worded deliberately. A roster header reading +// "37 members · 21 linked" is information; the same numbers with no explanation +// is a support ticket. And an empty roster has three unrelated causes — a Team +// with nobody in it, an audience rung that shows nobody, and a module that could +// not be asked — which is logic, not markup. + +/** How the roster describes a row's relationship to a site account (§3.2). */ +export const LINK_STATE = { linked: 'linked', unlinked: 'unlinked' } + +export function linkStateOf(member) { + return member && member.linked ? LINK_STATE.linked : LINK_STATE.unlinked +} + +/** + * The roster header line. + * + * The gap between the two numbers is the surfaced divergence Part 2 asks for: it + * must read as information rather than as a discrepancy, which is why the line + * says what each number IS instead of showing them side by side and hoping. + * + * `guests` is phase 4's forum grants and is omitted while there are none, so the + * line does not carry a permanent zero for a feature that has not shipped. + */ +export function rosterSummary({ members = 0, linked = 0, guests = 0 } = {}) { + const parts = [`${members} ${members === 1 ? 'member' : 'members'}`, `${linked} linked`] + if (guests > 0) parts.push(`${guests} forum ${guests === 1 ? 'guest' : 'guests'}`) + return parts.join(' · ') +} + +const MINUTE = 60_000 +const HOUR = 60 * MINUTE +const DAY = 24 * HOUR + +/** "just now" / "14 minutes ago" / "3 hours ago" / "2 days ago". */ +export function relativeTime(when, now = Date.now()) { + if (!when) return null + const ms = now - new Date(when).getTime() + if (!Number.isFinite(ms)) return null + if (ms < MINUTE) return 'just now' + if (ms < HOUR) { + const n = Math.floor(ms / MINUTE) + return `${n} ${n === 1 ? 'minute' : 'minutes'} ago` + } + if (ms < DAY) { + const n = Math.floor(ms / HOUR) + return `${n} ${n === 1 ? 'hour' : 'hours'} ago` + } + const n = Math.floor(ms / DAY) + return `${n} ${n === 1 ? 'day' : 'days'} ago` +} + +/** + * How a public page describes the projection's freshness (§2.4). + * + * Distinct from `teamAdmin.freshnessOf`, which is worded for an operator + * debugging a sync. A visitor needs one sentence about whether what they are + * looking at is current, and specifically must never be shown an unconfirmed + * empty projection as though it were a confirmed empty shard. + */ +export function freshnessNote(sync = {}, now = Date.now()) { + // Nothing supplies Teams here, so there is nothing to be stale ABOUT. A + // deployment with no game module is not a broken one. + if (!sync.configured) return null + if (!sync.lastSyncAt) return { tone: 'warn', text: 'Not yet confirmed against the game.' } + const ago = relativeTime(sync.lastSyncAt, now) + if (sync.stale) return { tone: 'warn', text: `Last confirmed ${ago} — the game may have moved on.` } + return { tone: 'idle', text: `Last confirmed ${ago}.` } +} + +/** + * Why a roster is empty, in the viewer's terms. + * + * Returns null when it is not empty. The three causes are genuinely different and + * reporting the wrong one is the failure this function exists to prevent: telling + * someone a guild has no members when in fact the module could not be asked is a + * statement about the game that happens to be false. + */ +export function emptyRosterReason(roster = {}) { + const members = roster.members || [] + if (members.length) return null + if (roster.projectionUnavailable) { + return 'The roster cannot be shown right now — the game module could not be reached.' + } + if (roster.configured && !roster.lastSyncAt) { + return 'This roster has not been confirmed against the game yet.' + } + return 'Nobody is in this Team.' +} + +/** + * Group feed items into days, newest first, preserving order within a day (§4.3). + * + * Keyed by local calendar date rather than by a UTC slice: "yesterday" is a + * property of where the reader is sitting, and a shard's evening raid landing at + * 00:30 UTC belongs on the day the players experienced it. + */ +export function groupByDay(items = [], locale = undefined) { + const days = [] + const byKey = new Map() + for (const item of items) { + const date = new Date(item.occurredAt) + if (Number.isNaN(date.getTime())) continue + const key = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}` + if (!byKey.has(key)) { + const day = { + key, + label: date.toLocaleDateString(locale, { year: 'numeric', month: 'long', day: 'numeric' }), + items: [], + } + byKey.set(key, day) + days.push(day) + } + byKey.get(key).items.push(item) + } + return days +} + +/** + * What to say under a feed that has been filtered. + * + * Only when there is something to say: a caller who saw everything is told + * nothing, and an anonymous caller is invited to sign in rather than simply + * informed that items exist which they cannot have. + */ +export function activityScopeNote(feed = {}, signedIn = false) { + if (feed.scope !== 'public') return null + return signedIn + ? 'Some entries are visible to members of this Team only.' + : 'Sign in as a member of this Team to see its members-only entries.' +} + +/** Sort for the index: most members first, then alphabetically. */ +export function sortTeams(teams = []) { + return [...teams].sort( + (a, b) => (b.memberCount || 0) - (a.memberCount || 0) || String(a.name).localeCompare(String(b.name)), + ) +} + +/** The index's search, over the two things a visitor knows a Team by. */ +export function filterTeams(teams = [], query = '') { + const q = query.trim().toLowerCase() + if (!q) return teams + return teams.filter( + (t) => String(t.name || '').toLowerCase().includes(q) || String(t.abbr || '').toLowerCase().includes(q), + ) +} diff --git a/client/src/main.jsx b/client/src/main.jsx index c2c3101..8ab9ff7 100644 --- a/client/src/main.jsx +++ b/client/src/main.jsx @@ -3,7 +3,8 @@ import { createRoot } from 'react-dom/client' import { BrowserRouter } from 'react-router-dom' import App from './App.jsx' import { publishSharedDependencies } from './modules/shared.js' -import { declareSlot } from './modules/registry.js' +import { declareSlot, registerFeatureProvider } from './modules/registry.js' +import { useCoreFlags } from './modules/coreFeatures.js' import './styles/theme.css' // Publish window.__rg BEFORE rendering and before any module chunk evaluates. @@ -18,8 +19,12 @@ publishSharedDependencies() // and namespace `uo`, so that the seam was exercised by real content from the // day it was built. That prediction paid out exactly as written: the extraction // deleted the registration and the hook it named, and SiteHeader was not touched. -// There is nothing for core to register now — no core nav row carries a -// `feature` — and the filter is a correct no-op until a module supplies one. +// +// Teams put a core row back on the seam. `feature: 'teams'` on the three Team nav +// rows resolves against owner id `core` (featureGate.js: a row with no `moduleId` +// belongs to core), and this is the provider that answers it — hiding the rows on +// a deployment that has no Teams at all, and failing open everywhere else. +registerFeatureProvider('core', 'core', useCoreFlags) // ── Extension slots (MODULE_API.md §3.7) ─────────────────────────────────── // @@ -50,6 +55,18 @@ declareSlot('admin.users.detail') // over `onDone`. With the slot unfilled the invitee goes straight to the portal, // which is what core's own code did whenever the flag was off. declareSlot('player.invite.accepted') +// The two Team slots (TEAMS.md §3.4, MODULE_API 1.6.0). Both named for a place: +// `team.overview` is the spot under the counts on a Team page, not "where the +// game puts guild stats", and `team.member.row` is the trailing cell of a roster +// row. Core renders the whole Team experience with both unfilled — the pages are +// core's, and a module adds to them rather than supplying them. +// +// `team.overview` is where a live "online now" strip belongs: core's online count +// is the durable floor refreshed at the reconcile interval (§3.3), and a module +// that already holds a live presence feed can render the current number here +// without core acquiring an SSE stack to do it. +declareSlot('team.overview') +declareSlot('team.member.row') // Core filled the first two itself until slice 3, with the components that were // inline in SiteFooter.jsx and UserDetail.jsx. Both are gone: the module fills diff --git a/client/src/modules/coreFeatures.js b/client/src/modules/coreFeatures.js new file mode 100644 index 0000000..c797157 --- /dev/null +++ b/client/src/modules/coreFeatures.js @@ -0,0 +1,56 @@ +import { useEffect, useState } from 'react' + +// Core's own feature provider (TEAMS.md §3.5, MODULE_API.md §3.3). +// +// Core registered one here until the module cutover, under owner id `core` and +// namespace `uo`, and it left with the shard rows. This brings the seam back with +// content that is genuinely core's: `teams` gates the Teams nav rows, and Teams +// are a core platform entity that a module merely populates. +// +// **What the flag actually answers is "does this deployment have Teams at all".** +// Not "may this viewer see them" — Team pages are public (§0.7) and the server +// gates them. On bare core, with no module supplying a Team provider and no rows +// left behind by one, `/teams` is a permanently empty page and a link to it is +// worse than no link. That is the whole job. +// +// It fails OPEN, like every other answer in this seam: while the request is in +// flight, and on any error, the hook returns `null`, which `buildFeatureGate` +// reads as "we do not know yet" and SHOWS the row. The page itself is the gate. +// The one thing a UI mistake must never do here is hide a surface from someone +// entitled to it — and a Teams link that leads somewhere empty is a far cheaper +// mistake than a Team page nobody can find. + +// `limit=1` because only `enabled` is wanted. The endpoint answers it whatever +// the page size, and asking for the default fifty would pull a roster's worth of +// counts into a nav decision. +const TEAMS_URL = '/api/v1/public/teams?limit=1' + +/** + * The hook core registers. Returns a Set-like of visible flags, or `null` while + * the answer is unknown. + * + * Fetched once per mount rather than subscribed: whether a deployment has Teams + * changes when a module is installed, which is a restart, not a session event. + */ +export function useCoreFlags() { + const [flags, setFlags] = useState(null) + + useEffect(() => { + let active = true + fetch(TEAMS_URL, { credentials: 'same-origin' }) + .then((res) => (res.ok ? res.json() : null)) + .then((body) => { + if (!active) return + // A body that does not carry `enabled` is an older server or a shape + // change, and both are "unknown" rather than "no". + if (!body || typeof body.enabled !== 'boolean') return + setFlags(new Set(body.enabled ? ['teams'] : [])) + }) + .catch(() => {}) // stays null: unknown shows the row + return () => { active = false } + }, []) + + return flags +} + +export default useCoreFlags diff --git a/client/src/routes/player/PlayerPortalLayout.jsx b/client/src/routes/player/PlayerPortalLayout.jsx index 12e9153..cd1c537 100644 --- a/client/src/routes/player/PlayerPortalLayout.jsx +++ b/client/src/routes/player/PlayerPortalLayout.jsx @@ -35,6 +35,7 @@ function Icon({ children, size = 16 }) { } const IconGear = () => const IconShield = () => +const IconTeams = () => // Exported because Admin -> Navigation edits this list. It stays declared here; // the editor may only relabel, reorder and hide what it finds (§7). No CORE row @@ -46,6 +47,12 @@ const IconShield = () =>