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