feat(teams): project rosters by audience rung, and add to the Team page

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 20:16:22 -05:00
parent 0d618599cf
commit d4aa5ade12
5 changed files with 210 additions and 3 deletions

View File

@@ -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