feat(guilds): a UO guild is a Team (Teams cutover 5/6) #16
55
client/src/components/TeamOverviewStrip.jsx
Normal file
55
client/src/components/TeamOverviewStrip.jsx
Normal file
@@ -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 (
|
||||||
|
<p
|
||||||
|
className="sans dim"
|
||||||
|
style={{ margin: '0 0 8px', fontSize: '0.86rem' }}
|
||||||
|
>
|
||||||
|
{total === 0
|
||||||
|
? 'Nobody is on the shard right now.'
|
||||||
|
: `${total} ${total === 1 ? 'player is' : 'players are'} on the shard right now.`}
|
||||||
|
{' '}
|
||||||
|
<span style={{ opacity: 0.7 }}>
|
||||||
|
The per-member marks above are as recent as the last roster sync.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -52,6 +52,7 @@ import PlayerCharacter from './routes/player/PlayerCharacter.jsx'
|
|||||||
import ShardStatusLink from './components/ShardStatusLink.jsx'
|
import ShardStatusLink from './components/ShardStatusLink.jsx'
|
||||||
import UserShardSections from './routes/admin/UserShardSections.jsx'
|
import UserShardSections from './routes/admin/UserShardSections.jsx'
|
||||||
import InviteGameAccountStep from './components/InviteGameAccountStep.jsx'
|
import InviteGameAccountStep from './components/InviteGameAccountStep.jsx'
|
||||||
|
import TeamOverviewStrip from './components/TeamOverviewStrip.jsx'
|
||||||
|
|
||||||
const ID = 'uo'
|
const ID = 'uo'
|
||||||
|
|
||||||
@@ -180,6 +181,18 @@ registry.registerFeatureProvider(ID, ID, useShardFlags)
|
|||||||
registry.registerExtension(ID, 'site.footer.status', ShardStatusLink)
|
registry.registerExtension(ID, 'site.footer.status', ShardStatusLink)
|
||||||
registry.registerExtension(ID, 'admin.users.detail', UserShardSections)
|
registry.registerExtension(ID, 'admin.users.detail', UserShardSections)
|
||||||
registry.registerExtension(ID, 'player.invite.accepted', InviteGameAccountStep)
|
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
|
// `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
|
// ever served, so there is nothing to re-check here. It is logged because a
|
||||||
|
|||||||
@@ -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')
|
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
|
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(
|
assert.deepEqual(
|
||||||
[...extensions.keys()].sort(),
|
[...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) {
|
for (const [slot, { id, Component }] of extensions) {
|
||||||
assert.equal(id, 'uo', `${slot} was filled under the wrong owner id`)
|
assert.equal(id, 'uo', `${slot} was filled under the wrong owner id`)
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const db = require('./teamProvider.db')
|
|||||||
const uoLinkConfig = require('../uoLinkConfig/uoLinkConfig.model')
|
const uoLinkConfig = require('../uoLinkConfig/uoLinkConfig.model')
|
||||||
const uoLinkSocket = require('../../utils/uoLinkSocket')
|
const uoLinkSocket = require('../../utils/uoLinkSocket')
|
||||||
const clilocs = require('../shardClilocs/shardClilocs.model')
|
const clilocs = require('../shardClilocs/shardClilocs.model')
|
||||||
|
const visibility = require('../../utils/shardVisibility')
|
||||||
|
|
||||||
const log = core.logger('teams')
|
const log = core.logger('teams')
|
||||||
|
|
||||||
@@ -251,4 +252,69 @@ function resolveUserId(row) {
|
|||||||
return Number.isInteger(fromLink) && fromLink > 0 ? fromLink : null
|
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 }
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const db = require('../model/teamProvider/teamProvider.db')
|
|||||||
const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
|
const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
|
||||||
const uoLinkSocket = require('../utils/uoLinkSocket')
|
const uoLinkSocket = require('../utils/uoLinkSocket')
|
||||||
const clilocs = require('../model/shardClilocs/shardClilocs.model')
|
const clilocs = require('../model/shardClilocs/shardClilocs.model')
|
||||||
|
const visibility = require('../utils/shardVisibility')
|
||||||
const provider = require('../model/teamProvider/teamProvider.model')
|
const provider = require('../model/teamProvider/teamProvider.model')
|
||||||
|
|
||||||
const saved = []
|
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.equal(answer.ok, true)
|
||||||
assert.deepEqual(answer.teams, [])
|
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