fix(rust): nothing names who is online by default
All checks were successful
PR Checks / client-build (pull_request) Successful in 27s
PR Checks / frozen-manifest (pull_request) Successful in 51s
PR Checks / server-tests (pull_request) Successful in 8m6s

The org lead's rule, settled 2026-09-22: who is online is always the
narrowest audience - staff - unless an operator deliberately widens it,
and a count is fine where a list of names is not.

The public site broke that in three places since phase 4. The Online
tab named every player, the feed carried joins, respawns, deaths, chat
and tallies, and the leaderboard's lastSeen - refreshed every minute by
a gather tally - said who was on as plainly as either. All three now
sit behind one setting:

* PRESENCE_KINDS, a subset of the public allowlist, gated per request.
  Below the audience the feed keeps the server's own story (wipe, start,
  shutdown) and says presenceHidden rather than looking quiet.
* the Online route answers { players: [], hidden, count, audience } -
  same shape, so an older client renders empty rather than breaking.
* rungs staff / signed_in / public, fleet-wide default in a new
  rust_settings table with an optional per-server override on
  rust_servers; an unknown stored word narrows to staff.
* the viewer's standing is RE-READ from the users row (ctx.users.getById),
  not taken from the token, so a demotion or a ban applies on the next
  request. Walked: a moderator demoted mid-session lost the roll call on
  the same cookie.
* per-viewer answers are Cache-Control: private, no-store.
* GET/PUT /admin/rust/visibility (requireRole admin) and an admin page,
  Rust visibility; every save is one activity-log row.

The browser walk also found every empty state in this module rendering
as a blank box. Core's EmptyState renders children only; this module
passed title/message (the shape the Integration Kit template teaches)
and React dropped both without a word. Fixed module-side with a small
Empty wrapper - nothing core or module-uo renders changes - and a client
test that refuses a titled EmptyState or a PageHeader subtitle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-09-23 00:30:08 -05:00
parent 480a99f661
commit be44839896
31 changed files with 1739 additions and 33 deletions

View File

@@ -0,0 +1,207 @@
// ── Who may see who is online ─────────────────────────────────────────────
//
// The org lead's rule, settled 2026-09-22: **nothing tells who is online by
// default.** It is always the lowest blast radius — staff — unless an operator
// deliberately widens it, and a COUNT of players is fine where a list of names
// is not.
//
// "Who is online" is wider than the Online tab. Every frame that says a named
// player was on the server at a given moment says it: a connect, a respawn, a
// death, a chat line, a gather tally (`catalogue.PRESENCE_KINDS`), and a
// leaderboard row's `lastSeen`, which a tally refreshes every minute while
// somebody plays. All of them sit behind this one setting.
//
// ── The audiences ─────────────────────────────────────────────────────────
//
// staff an admin or a moderator — the two roles every Team surface in
// core also means by "staff"
// signed_in any active website account
// public anybody, signed in or not
//
// Ordered, each rung implying the ones below it. The names line up with phase
// 14's map-layer switches (public / players / admin) so that one layer can take
// this over rather than sit beside it.
//
// ── Two fallbacks, deliberately asymmetric ────────────────────────────────
//
// An unrecognised VIEWER reads as the bottom rung and an unrecognised
// REQUIREMENT reads as the top one, so a value nobody expected always loses.
// One shared fallback cannot do that: whichever way it points, it fails open on
// one side. module-uo's shard visibility learned this the hard way; the rule is
// copied here rather than rediscovered.
const core = require('../../core')
const db = require('./visibility.db')
const log = core.logger('visibility')
const AUDIENCES = Object.freeze(['public', 'signed_in', 'staff'])
const RANK = new Map(AUDIENCES.map((a, i) => [a, i]))
/** The narrowest rung, and the default wherever nothing has been chosen. */
const DEFAULT_PRESENCE = 'staff'
/** The `rust_settings` key the fleet default lives under. */
const PRESENCE_KEY = 'presence.audience'
const isAudience = (value) => RANK.has(value)
const viewerRank = (level) => RANK.get(level) ?? 0
const requiredRank = (level) => RANK.get(level) ?? RANK.get('staff')
/** Does a viewer at `viewer` satisfy a requirement of `required`? */
const meets = (viewer, required) => viewerRank(viewer) >= requiredRank(required)
/**
* The viewer's rung, re-read from the database.
*
* `getUserFromRequest` decodes a token and nothing more — the role in it is the
* role the account had when it signed in. For a gate on who may see who is
* online, that is not good enough: a moderator demoted this morning would keep
* the roll call until their token expired, and a banned account would keep
* reading it too. So the token only says WHO; the row says what they are now.
*
* Any failure resolves to `public` — the bottom rung — because an unanswerable
* question about somebody's standing must grant nothing.
*/
async function viewerLevel(req) {
try {
const claimed = req.user || core.auth.getUserFromRequest(req)
if (!claimed || claimed.id == null) return 'public'
const user = await core.users.getById(claimed.id)
if (!user) return 'public'
if (user.status && user.status !== 'active') return 'public'
if (user.role === 'admin' || user.role === 'moderator') return 'staff'
return 'signed_in'
} catch (err) {
log.warn('could not resolve the viewer; treating them as anonymous', { error: err.message })
return 'public'
}
}
/** A stored value as an audience, narrowing anything this build does not recognise. */
function normalise(value) {
return isAudience(value) ? value : DEFAULT_PRESENCE
}
/** The fleet default. */
async function fleetPresence() {
const stored = await db.getSetting(PRESENCE_KEY)
return stored == null ? DEFAULT_PRESENCE : normalise(stored)
}
/**
* The audience that applies to one server: its override if it has one, the
* fleet default otherwise.
*
* A server that does not exist gets the fleet default, which is the right answer
* for the routes that call this: they answer an empty list for an unknown id,
* and an empty list is empty at every rung.
*/
async function presenceFor(serverId) {
const override = await db.getServerPresence(serverId)
if (override != null) return normalise(override)
return fleetPresence()
}
/**
* Everything a public route needs in one call: may this viewer see who is on
* this server?
*
* Throws nothing. A setting that cannot be read resolves to "no" — the routes
* that ask would otherwise have to choose between a 500 and publishing names.
*/
async function canSeePresence(req, serverId) {
try {
const [level, required] = await Promise.all([viewerLevel(req), presenceFor(serverId)])
return { visible: meets(level, required), level, required }
} catch (err) {
log.warn('could not resolve presence visibility; withholding it', { server: serverId, error: err.message })
return { visible: false, level: 'public', required: DEFAULT_PRESENCE }
}
}
/** The admin screen's read: the fleet default and every server beside it. */
async function describe() {
const [fleet, servers] = await Promise.all([fleetPresence(), db.listServerPresence()])
return {
audiences: [...AUDIENCES],
presence: {
fleet,
servers: servers.map((s) => {
const override = s.presence == null ? null : normalise(s.presence)
return {
id: s.id,
name: s.name,
enabled: Boolean(s.enabled),
override,
effective: override || fleet,
}
}),
},
}
}
/**
* The admin screen's write.
*
* `fleet` is optional; `servers` maps an id to an audience, or to `null` to
* clear its override. Validated whole before anything is written, so a request
* naming one unknown server changes nothing rather than half of what it asked.
*
* Resolves `{ ok, changed }`, or `{ ok: false, status, message }` — a refusal is a
* sentence the page can show.
*/
async function update({ fleet, servers } = {}, actor = null) {
if (fleet !== undefined && !isAudience(fleet)) {
return { ok: false, status: 400, message: `"${fleet}" is not an audience. Choose one of: ${AUDIENCES.join(', ')}.` }
}
const changes = Object.entries(servers || {})
for (const [id, value] of changes) {
if (value !== null && !isAudience(value)) {
return { ok: false, status: 400, message: `"${value}" is not an audience for server ${id}.` }
}
// eslint-disable-next-line no-await-in-loop
if ((await db.getServerPresence(id)) === undefined) {
return { ok: false, status: 404, message: `There is no server called ${id}.` }
}
}
const userId = actor && actor.id != null ? actor.id : null
if (fleet !== undefined) await db.setSetting(PRESENCE_KEY, fleet, userId)
for (const [id, value] of changes) {
// eslint-disable-next-line no-await-in-loop
await db.setServerPresence(id, value)
}
// What was written, for the controller's audit row. Recorded there rather than
// here because the activity log takes the REQUEST (who, from where), and a
// model that took a request would be a model that could only be called by one.
return {
ok: true,
changed: {
...(fleet !== undefined ? { fleet } : {}),
servers: Object.fromEntries(changes.map(([id, value]) => [id, value === null ? 'inherit' : value])),
},
}
}
module.exports = {
AUDIENCES,
DEFAULT_PRESENCE,
PRESENCE_KEY,
isAudience,
meets,
normalise,
viewerLevel,
fleetPresence,
presenceFor,
canSeePresence,
describe,
update,
}