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>
321 lines
14 KiB
JavaScript
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 }
|