Files
Module-uo/server/model/teamProvider/teamProvider.model.js
wtclaude d4aa5ade12 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>
2026-08-17 20:16:22 -05:00

321 lines
14 KiB
JavaScript

// ── module-uo's Team provider ──────────────────────────────────────────────
//
// The three questions core asks this module about Teams
// (docs/website/MODULE_API.md — `api.registerTeamProvider`, and TEAMS.md §2.3).
// A UO guild is a Team; this file is the whole of the translation.
//
// **Every method returns an envelope, and answering `{ ok: false }` is a normal
// outcome, not a failure to handle.** Core's contract is that module
// unavailability becomes staleness and never emptiness, and the only way this
// module can say "I cannot answer" is to say so — an empty array would be read as
// an authoritative "there are none", which during a cold start is how every
// roster on the site gets emptied. So the guard below is the most important code
// in the file, and it is deliberately conservative: **an unreachable or
// never-connected sidecar refuses, rather than reporting the board it happens to
// still hold.**
//
// The board IS durable and would survive a sidecar outage, which is exactly what
// makes this tempting to get wrong. The reason to refuse anyway: core cannot tell
// a board that is five minutes stale from one that is five days stale, and it
// makes destructive decisions — archiving Teams, departing members — from a
// complete answer. Reporting a stale board as authoritative would license those.
const core = require('../../core')
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')
/**
* ServUO's five stock rank names, by the cliloc id the game names them with.
*
* A fallback, not the source of truth: the operator's own cliloc table is consulted
* first, and a shard with custom rank definitions sends a literal string that beats
* both. This exists because the cliloc table is populated only if someone ran the
* client-file extraction, and a roster on a shard that has not should still say
* "Warlord" rather than nothing.
*/
const STANDARD_RANK_NAMES = {
1062959: 'Leader',
1062960: 'Warlord',
1062961: 'Emissary',
1062962: 'Member',
1062963: 'Ronin',
}
/** A refusal, in the shape core reads (§2.3). */
const refuse = (reason) => ({ ok: false, reason })
/**
* Is the bridge in a state where the board can be trusted as current?
*
* The board is only as good as the socket that fills it. Three states refuse, and
* they are asked in this order because each is a different thing being wrong:
*
* - **no uo-link configured** — there is no shard behind this website at all;
* - **the integration is disabled** — an admin turned it off, and the board is
* frozen at whatever it held;
* - **the socket is not connected** — the board is a snapshot of unknown age.
*
* The in-process socket state is preferred over the persisted status column,
* which is written on transitions: a process that has just started has not
* transitioned yet, so the column can still say `connected` from the last run
* while this process has never opened a socket.
*/
async function boardIsCurrent() {
const config = await uoLinkConfig.getSafe()
if (!config || !config.baseUrl) return { ok: false, reason: 'no uo-link configured' }
if (!config.enabled) return { ok: false, reason: 'the uo-link integration is disabled' }
const state = uoLinkSocket.getState()
if (!state || !state.connected) {
return { ok: false, reason: 'the uo-link socket is not connected; the guild board may be stale' }
}
return { ok: true }
}
/**
* `getTeams()` — every guild on the board.
*
* `externalId` is the ServUO `Guild.Id`, which survives a rename: renaming a
* guild in-game keeps the id, so core sees "an id whose name changed" and applies
* its rename rule (archive plus create). That mapping is this module's to make —
* only the game knows what identity survives what (§10.5).
*
* `meta` carries the alliance, opaquely. Core stores and displays it and never
* branches on it, which is what lets a UO concept reach a Team page without core
* acquiring an opinion about alliances.
*/
async function getTeams() {
const ready = await boardIsCurrent()
if (!ready.ok) return refuse(ready.reason)
try {
const rows = await db.listGuilds()
return {
ok: true,
complete: true,
teams: rows.map((row) => ({
externalId: String(row.id),
name: row.name,
abbr: row.abbr || null,
meta: row.alliance ? { alliance: row.alliance } : null,
})),
}
} catch (err) {
log.warn('getTeams failed', { message: err.message })
return refuse(`guild board unreadable: ${err.message}`)
}
}
/**
* `getTeamMembers(externalId)` — one guild's roster.
*
* **A guild with no roster rows is refused, not reported empty**, unless the board
* itself says the guild has no members. Protocol 4's roster arrives on its own
* frames, separately from the `guild.update` that creates the board row, so there
* is a real window — a fresh guild, or a website that connected between the two —
* where core would otherwise be told authoritatively that a 155-member guild has
* nobody in it. The board's own `members` count is what distinguishes the two,
* and it is the only thing that can.
*/
async function getTeamMembers(externalId) {
const ready = await boardIsCurrent()
if (!ready.ok) return refuse(ready.reason)
try {
const [guild] = await db.findGuild(externalId)
if (!guild) return refuse(`guild ${externalId} is not on the board`)
const rows = await db.listGuildMembers(externalId)
if (!rows.length && guild.members > 0) {
return refuse(`roster for guild ${externalId} has not arrived yet (board says ${guild.members} members)`)
}
const labels = await rankLabels(rows)
return {
ok: true,
complete: true,
members: rows.map((row) => ({
memberKey: row.serial,
displayName: row.name || null,
rankLabel: labels.get(row.serial) || null,
// Rank 4 is Leader, and several members can hold it. A NULL rank is not a
// leader: the shard withholds the rank for a staff account rather than
// publishing the Leader its getter falsely reports, and "not known" must
// never be read as "leads this guild".
leader: Number.isInteger(row.rank) && row.rank >= db.LEADER_RANK,
online: Boolean(row.is_online),
userId: resolveUserId(row),
})),
}
} catch (err) {
log.warn('getTeamMembers failed', { externalId, message: err.message })
return refuse(`roster unreadable: ${err.message}`)
}
}
/**
* `getTeamLeaders(externalId)` — everyone at leader rank.
*
* **All of them, which is why Protocol 4 grew a per-member rank.** The guild board
* carries one `leader_serial`, so before the rank amendment this could only ever
* name a single member, while a UO guild routinely has several at rank 4 and
* TEAMS.md §2.5 treats multiple leaders as the normal case.
*
* The board's own `leader_serial` is folded in as a floor. It is the guild's
* founder-leader and it comes from a different frame (`guild.update`), so on a
* shard whose roster has not been re-emitted since the amendment it is the only
* leadership signal there is — and it should never be *lost* by moving to ranks.
*/
async function getTeamLeaders(externalId) {
const ready = await boardIsCurrent()
if (!ready.ok) return refuse(ready.reason)
try {
const [guild] = await db.findGuild(externalId)
if (!guild) return refuse(`guild ${externalId} is not on the board`)
const rows = await db.listGuildLeaders(externalId)
const leaders = rows.map((r) => r.serial)
if (guild.leader_serial && !leaders.includes(guild.leader_serial)) {
leaders.push(guild.leader_serial)
}
return { ok: true, leaders }
} catch (err) {
log.warn('getTeamLeaders failed', { externalId, message: err.message })
return refuse(`leadership unreadable: ${err.message}`)
}
}
/**
* Resolve each member's rank to a display label, keyed by serial.
*
* The shard sends the rank's NAME as the game states it — a cliloc id for the five
* standard ranks, or a literal string for a custom rank definition — and never a
* resolved label, because ServUO ships no text for those clilocs. This module does
* have a cliloc table, which is why the resolution belongs here.
*
* Three sources, in order: a custom string wins, then the operator's cliloc table,
* then the five standard names. The last exists because the cliloc table is
* populated only if someone ran the client extraction, and a shard that has not
* should still read "Warlord" rather than nothing.
*
* Never throws: a rank label is decoration on a roster, and a lookup failure must
* not turn a good roster into a refusal.
*/
async function rankLabels(rows) {
const out = new Map()
const wanted = []
for (const row of rows) {
if (row.rank_name) {
out.set(row.serial, row.rank_name)
} else if (Number.isInteger(row.rank_cliloc)) {
wanted.push(row.rank_cliloc)
}
}
let resolved = new Map()
if (wanted.length) {
try {
resolved = await clilocs.resolveMany(wanted)
} catch (err) {
log.warn('rank cliloc lookup failed; falling back to the standard names', { message: err.message })
}
}
for (const row of rows) {
if (out.has(row.serial) || !Number.isInteger(row.rank_cliloc)) continue
const label = resolved.get(row.rank_cliloc) || STANDARD_RANK_NAMES[row.rank_cliloc] || null
if (label) out.set(row.serial, label)
}
return out
}
/**
* The site account behind a character, or null.
*
* `web_id` is what the shard itself asserted when it emitted the roster; the
* account-link join is the fallback for a member whose roster row predates their
* link. Both are coerced through the same check, because `web_id` arrives from
* the wire as a string.
*/
function resolveUserId(row) {
const fromRoster = Number.parseInt(row.web_id, 10)
if (Number.isInteger(fromRoster) && fromRoster > 0) return fromRoster
const fromLink = Number.parseInt(row.linked_user_id, 10)
return Number.isInteger(fromLink) && fromLink > 0 ? fromLink : null
}
/**
* 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 }