From d4aa5ade1282b45f881ec548b147c124401dfba5 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 17 Aug 2026 20:16:22 -0500 Subject: [PATCH] feat(teams): project rosters by audience rung, and add to the Team page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The module's half of TEAMS.md phase 3. `projectRoster` is the optional fourth provider method and the only one core calls on a request path. Core holds the roster and owns its public shape; the question that is this module's is who is allowed to look, because the audience rungs and their configuration live here. The answer is all-or-nothing, which is the honest translation rather than a shortcut: a rung is a property of the FEATURE, and there is no configuration in which some members of a guild are public and others are not. The refusal semantics INVERT here, and the tests say so. For the other three methods a refusal means "change nothing" and an empty array would be destructive. Core fails CLOSED on this one, so the dangerous answer is the opposite — returning every key because the config could not be read would publish a roster an operator gated to staff. Every path that cannot reach a confident answer refuses, including the catch. The anonymous case is answered directly rather than by handing `viewerLevel` a synthetic request. Given one with no `req.user` it falls through to `auth.getUserFromRequest`, which expects real cookies and throws on a fake — and that throw would have become a refusal, so every anonymous visitor would have been served an empty roster on a shard whose guilds are public. Caught by the tests, not by reading. `team.overview` gets a live population reading beside core's stored one. Core's number comes from the last roster sync and is coarse by construction; this is the `presence.online` feed this module already holds. It is explicitly not a per-Team presence figure — the shard publishes a global aggregate and no per-guild breakdown exists on the wire, so claiming one would be inventing a number — and it renders nothing at all when it has nothing true to say. `team.member.row` is left unfilled. The useful thing to put there is a link to the character behind a row, and the props core can supply do not identify one: the member key and the site account id are withheld from every public roster. An empty cell beats a guess. Co-Authored-By: Claude --- client/src/components/TeamOverviewStrip.jsx | 55 +++++++++++++++ client/src/entry.jsx | 13 ++++ client/test/registration.test.js | 8 ++- .../model/teamProvider/teamProvider.model.js | 68 +++++++++++++++++- server/test/teamProvider.test.js | 69 +++++++++++++++++++ 5 files changed, 210 insertions(+), 3 deletions(-) create mode 100644 client/src/components/TeamOverviewStrip.jsx diff --git a/client/src/components/TeamOverviewStrip.jsx b/client/src/components/TeamOverviewStrip.jsx new file mode 100644 index 0000000..d5116a2 --- /dev/null +++ b/client/src/components/TeamOverviewStrip.jsx @@ -0,0 +1,55 @@ +import { useMemo } from 'react' +import { useShardFeed } from '../lib/useShardFeed.js' + +// What this module contributes to a core Team page (`team.overview`, +// TEAMS.md §3.3, §3.4). +// +// **Core already renders an online count, and this does not replace it.** Core's +// number is written by the Team sync from the roster the module answered, so it +// is durable and refreshed at the reconcile interval — coarse by construction. +// This one is live: the same `presence.online` feed the site header's widget +// already consumes, which this module holds and core does not. Core's is the +// floor; this is the current reading, and it says which it is rather than +// silently disagreeing with the number three lines above it. +// +// It is emphatically NOT a per-Team presence feed. The shard publishes a global +// online aggregate and no per-guild breakdown exists on the wire, so claiming one +// here would be inventing a number. What it can honestly say is how many players +// are on the shard right now, next to a roster whose own online marks are as old +// as the last sync — which is the context a reader of that roster is missing. +// +// Renders nothing at all until the feed produces something. An empty slot is the +// correct output when there is nothing true to add (§3.7): core's page is +// complete without it, and a panel reading "unavailable" would be this module +// making core's page worse than it is with no module installed. + +const PRESENCE_KINDS = new Set(['presence.online']) + +export default function TeamOverviewStrip() { + const { events } = useShardFeed({ filter: PRESENCE_KINDS, max: 2 }) + const snapshot = events[0] + + const total = useMemo(() => { + const n = Number(snapshot?.count) + return Number.isFinite(n) ? n : null + }, [snapshot]) + + // No feed yet, a disabled integration, or a shard that is down. All three are + // "nothing to add", and none of them is worth a box saying so. + if (total == null) return null + + return ( +

+ {total === 0 + ? 'Nobody is on the shard right now.' + : `${total} ${total === 1 ? 'player is' : 'players are'} on the shard right now.`} + {' '} + + The per-member marks above are as recent as the last roster sync. + +

+ ) +} diff --git a/client/src/entry.jsx b/client/src/entry.jsx index 3b78cf0..bb60e52 100644 --- a/client/src/entry.jsx +++ b/client/src/entry.jsx @@ -52,6 +52,7 @@ import PlayerCharacter from './routes/player/PlayerCharacter.jsx' import ShardStatusLink from './components/ShardStatusLink.jsx' import UserShardSections from './routes/admin/UserShardSections.jsx' import InviteGameAccountStep from './components/InviteGameAccountStep.jsx' +import TeamOverviewStrip from './components/TeamOverviewStrip.jsx' const ID = 'uo' @@ -180,6 +181,18 @@ registry.registerFeatureProvider(ID, ID, useShardFlags) registry.registerExtension(ID, 'site.footer.status', ShardStatusLink) registry.registerExtension(ID, 'admin.users.detail', UserShardSections) registry.registerExtension(ID, 'player.invite.accepted', InviteGameAccountStep) +// The fourth, and the first that was never core's: `team.overview` is new in +// 1.6.0 and core renders the whole Team page without it (TEAMS.md §3.4). This +// adds a live reading beside core's stored one, and renders nothing when it has +// nothing true to say. +// +// `team.member.row` is declared by core and deliberately LEFT UNFILLED. Its +// useful contents would be a link to the character behind a roster row, and the +// props core can supply do not identify one: the member key and the site account +// id are withheld from every public roster (§3.2), so this module would be +// guessing from a display name. Filling it with a guess is worse than an empty +// cell. +registry.registerExtension(ID, 'team.overview', TeamOverviewStrip) // `module.json`'s `coreApi` range is checked by the loader before this file is // ever served, so there is nothing to re-check here. It is logged because a diff --git a/client/test/registration.test.js b/client/test/registration.test.js index 24e536f..ba9de03 100644 --- a/client/test/registration.test.js +++ b/client/test/registration.test.js @@ -166,11 +166,15 @@ it('a nav row that gates on a feature is gated by a namespace this module provid assert.ok(registered.providers.has('uo'), 'rows carry feature gates but no provider was registered') }) -it('fills the three extension slots, each with a component', () => { +it('fills the four extension slots, each with a component', () => { const { extensions } = registered + // `team.member.row` is core-declared and deliberately absent: the props core + // can supply do not identify a character, because the member key and the site + // account id are withheld from every public roster (TEAMS.md §3.2). An empty + // cell beats a guess. assert.deepEqual( [...extensions.keys()].sort(), - ['admin.users.detail', 'player.invite.accepted', 'site.footer.status'], + ['admin.users.detail', 'player.invite.accepted', 'site.footer.status', 'team.overview'], ) for (const [slot, { id, Component }] of extensions) { assert.equal(id, 'uo', `${slot} was filled under the wrong owner id`) diff --git a/server/model/teamProvider/teamProvider.model.js b/server/model/teamProvider/teamProvider.model.js index 41f5d2b..8b5b76b 100644 --- a/server/model/teamProvider/teamProvider.model.js +++ b/server/model/teamProvider/teamProvider.model.js @@ -25,6 +25,7 @@ const db = require('./teamProvider.db') const uoLinkConfig = require('../uoLinkConfig/uoLinkConfig.model') const uoLinkSocket = require('../../utils/uoLinkSocket') const clilocs = require('../shardClilocs/shardClilocs.model') +const visibility = require('../../utils/shardVisibility') const log = core.logger('teams') @@ -251,4 +252,69 @@ function resolveUserId(row) { return Number.isInteger(fromLink) && fromLink > 0 ? fromLink : null } -module.exports = { getTeams, getTeamMembers, getTeamLeaders, boardIsCurrent } +/** + * Which roster rows a viewer may see (TEAMS.md §3.3, MODULE_API 1.6.0). + * + * The optional fourth provider method, and the only one core calls on a REQUEST + * path rather than from the reconciler. Core holds the roster and its public + * shape; the question that is this module's is "who is allowed to look", because + * the audience rungs and their configuration live here (`utils/shardVisibility`) + * and core does not know what a rung is. + * + * **The answer is all-or-nothing, and that is correct rather than a shortcut.** + * A rung is a property of the FEATURE, not of a member: `guilds` is either + * visible to this viewer or it is not, and there is no configuration in which + * some members of a guild are public and others are not. Returning every key or + * none is the honest translation of the model this module actually has. + * + * **A refusal here costs visibility, not staleness.** Core fails closed on this + * one call — an unanswered visibility question serves an empty roster rather than + * an unprojected one — so every path below that cannot reach a confident answer + * refuses deliberately, and the catch does too. That is the opposite of the rule + * governing the other three methods, and it is the right way round: for a roster + * SYNC an unanswered call must change nothing, and for a roster READ it must + * publish nothing. + * + * Note what this does NOT do: strip fields. `acct` and `webId` are the leak this + * module's projection exists to prevent on the live feed, and neither is in + * core's roster shape at all — core withholds the member key and the site account + * id from every public roster whatever this returns. So there is nothing here to + * redact, only rows to withhold. + */ +async function projectRoster(externalId, members, viewer) { + try { + const config = await visibility.getConfig() + const feature = config.guilds + // An admin turned guilds off. Nobody sees a roster, including staff — the + // switch means "this shard does not publish guild data", not "publish it + // quietly". + if (!feature || !feature.enabled) return { ok: true, members: [] } + + // `viewerLevel` reads a REQUEST; core hands over a described viewer instead, + // which is deliberate — it keeps the `users` row out of the contract. + // + // The no-viewer case is answered here rather than by handing `viewerLevel` an + // empty object: given a request with no `req.user` it falls through to + // `auth.getUserFromRequest`, which expects real cookies and headers and + // throws on a synthetic one. That throw would land in the catch below and + // become a REFUSAL, so every anonymous visitor would have been served an + // empty roster on a shard whose guilds are public. Anonymous is a known + // answer, not a failed lookup. + const level = viewer + ? await visibility.viewerLevel({ user: { id: viewer.userId, role: viewer.role } }) + : 'anonymous' + if (!visibility.meets(level, feature.audience)) return { ok: true, members: [] } + + return { ok: true, members: members.map((m) => m.member_key).filter(Boolean) } + } catch (err) { + // Core reads this as "withhold the roster". Saying so is the whole point: the + // alternative — answering with every key because the config read failed — + // publishes a roster an operator may have gated to staff. + log.warn('projectRoster could not resolve visibility; withholding the roster', { + externalId, message: err.message, + }) + return refuse(`visibility could not be resolved: ${err.message}`) + } +} + +module.exports = { getTeams, getTeamMembers, getTeamLeaders, projectRoster, boardIsCurrent } diff --git a/server/test/teamProvider.test.js b/server/test/teamProvider.test.js index 3305fb3..c5c28fc 100644 --- a/server/test/teamProvider.test.js +++ b/server/test/teamProvider.test.js @@ -27,6 +27,7 @@ const db = require('../model/teamProvider/teamProvider.db') const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model') const uoLinkSocket = require('../utils/uoLinkSocket') const clilocs = require('../model/shardClilocs/shardClilocs.model') +const visibility = require('../utils/shardVisibility') const provider = require('../model/teamProvider/teamProvider.model') const saved = [] @@ -335,3 +336,71 @@ test('an empty board is an authoritative empty list — the shard really has no assert.equal(answer.ok, true) assert.deepEqual(answer.teams, []) }) + +// ── projectRoster (TEAMS.md §3.3) ────────────────────────────────────────── +// +// The refusal semantics INVERT here and that is the point of these tests. For +// the three methods above, a refusal means "change nothing" and an empty array +// would be destructive. For this one, core fails CLOSED — a refusal withholds the +// roster — so the dangerous answer is the opposite: returning every key because +// the config could not be read would publish a roster an operator gated to staff. + +const rows = [{ member_key: '0x1' }, { member_key: '0x2' }] + +function guilds(feature) { + patch(visibility, 'getConfig', async () => ({ guilds: feature })) +} + +test('a viewer at or above the audience sees every row', async () => { + guilds({ enabled: true, audience: 'anonymous' }) + const answer = await provider.projectRoster('1', rows, null) + assert.equal(answer.ok, true) + assert.deepEqual(answer.members, ['0x1', '0x2']) +}) + +test('a viewer below the audience sees none — authoritatively, not as a refusal', async () => { + // `ok: true` with an empty list is the correct answer here: this module KNOWS + // the viewer may see nothing. Core renders an empty roster rather than an + // error, which is what a gated shard is supposed to look like. + guilds({ enabled: true, audience: 'staff' }) + const answer = await provider.projectRoster('1', rows, { userId: 7, role: 'player' }) + assert.equal(answer.ok, true) + assert.deepEqual(answer.members, []) +}) + +test('an admin clears every audience', async () => { + guilds({ enabled: true, audience: 'admin' }) + const answer = await provider.projectRoster('1', rows, { userId: 1, role: 'admin' }) + assert.deepEqual(answer.members, ['0x1', '0x2']) +}) + +test('a disabled guilds feature hides the roster from everyone, staff included', async () => { + // The switch means "this shard does not publish guild data", not "publish it + // quietly to staff". + guilds({ enabled: false, audience: 'anonymous' }) + const answer = await provider.projectRoster('1', rows, { userId: 1, role: 'admin' }) + assert.equal(answer.ok, true) + assert.deepEqual(answer.members, []) +}) + +test('an unreadable visibility config REFUSES rather than publishing', async () => { + // The inversion, stated. Core reads this as "withhold", which is the only safe + // reading of "I could not work out who is allowed to look". + patch(visibility, 'getConfig', async () => { throw new Error('pool down') }) + const answer = await provider.projectRoster('1', rows, null) + assert.equal(answer.ok, false) + assert.match(answer.reason, /visibility could not be resolved/) +}) + +test('an absent viewer is anonymous, not an error', async () => { + guilds({ enabled: true, audience: 'logged_in' }) + const answer = await provider.projectRoster('1', rows, null) + assert.equal(answer.ok, true) + assert.deepEqual(answer.members, [], 'anonymous does not meet logged_in') +}) + +test('rows with no member key are dropped rather than answered as blanks', async () => { + guilds({ enabled: true, audience: 'anonymous' }) + const answer = await provider.projectRoster('1', [{ member_key: '0x1' }, { member_key: null }], null) + assert.deepEqual(answer.members, ['0x1']) +})