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

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