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:
@@ -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'])
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user