// ── 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, }