Four event verbs and the announce leg, per PLAN.md §29: - rust.participation.open / .collect: the plugin counts who takes part (seconds, kills or both, in a zone this run opened or the whole server) and collect files them as the run's participants, keyed by Steam id. - rust.kit.entitle: the five recipient modes (D101), rows in the new rust_perm_run_grants (D84) unioned into the permission push, one extra use of the kit per reward as site-held credits on perm.sync (D103), and the rust.kit.entitled notice deferred from phase 10 (D64). - rust.announce: one server or every server (D105). - rust.chat announce leg, speaking only on servers whose new news switch is on (D104) - a card on Admin -> Rust visibility (D106). Budgets rust.grants and rust.announcements; the kit source and four fixed-choice sources (core has no enum param type). rust_perm_run_grants carries core's idempotency key so a revert of a lost answer can find its rows. Protocol 10. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
287 lines
11 KiB
JavaScript
287 lines
11 KiB
JavaScript
// ── 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)
|
|
|
|
// ── Who may see a clan's roster (phase 9, D48) ────────────────────────────
|
|
//
|
|
// The same rule applied to a roster: a roster says who is in a clan and, inside
|
|
// its audience, which of them is on. So it defaults to the clan's OWN members
|
|
// plus staff, and an operator widens it deliberately.
|
|
//
|
|
// members staff, and a website account linked to one of the clan's members
|
|
// signed_in any active website account
|
|
// public anybody
|
|
//
|
|
// One fleet-wide setting (D48), deliberately without a per-server override: the
|
|
// presence override exists because a PvE server may publish a roll call a PvP one
|
|
// must not, and a roster is the same answer on every server of the fleet.
|
|
//
|
|
// **Widening it widens online status too.** Core's `projectRoster` can withhold a
|
|
// roster's rows but not its fields, so there is no rung that shows who is in a
|
|
// clan and hides which of them is on. The admin page says so beside the switch.
|
|
const CLAN_AUDIENCES = Object.freeze(['public', 'signed_in', 'members'])
|
|
|
|
/** The narrowest rung, and the default until an operator chooses. */
|
|
const DEFAULT_CLAN_ROSTER = 'members'
|
|
|
|
/** The `rust_settings` key the roster audience lives under. */
|
|
const CLAN_ROSTER_KEY = 'clans.roster.audience'
|
|
|
|
const isClanAudience = (value) => CLAN_AUDIENCES.includes(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 clan roster audience. An unrecognised stored word narrows to `members`,
|
|
* and a read that fails throws — every caller answers "no" on a throw, which is
|
|
* the direction a roster must fail in.
|
|
*/
|
|
async function clanRosterAudience() {
|
|
const stored = await db.getSetting(CLAN_ROSTER_KEY)
|
|
return isClanAudience(stored) ? stored : DEFAULT_CLAN_ROSTER
|
|
}
|
|
|
|
/** The admin screen's read: the fleet default and every server beside it. */
|
|
async function describe() {
|
|
const [fleet, servers, clanRoster] = await Promise.all([
|
|
fleetPresence(),
|
|
db.listServerPresence(),
|
|
clanRosterAudience(),
|
|
])
|
|
return {
|
|
audiences: [...AUDIENCES],
|
|
clans: { audiences: [...CLAN_AUDIENCES], roster: clanRoster },
|
|
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,
|
|
}
|
|
}),
|
|
},
|
|
// D104/D106: whether a published news post is said in each server's chat.
|
|
// On this page because it is the one that lists every server with a setting
|
|
// of its own, and it answers the same kind of question — what a server shows.
|
|
news: {
|
|
servers: servers.map((s) => ({ id: s.id, name: s.name, enabled: Boolean(s.enabled), on: Boolean(Number(s.announceNews)) })),
|
|
},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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, clanRoster, news } = {}, actor = null) {
|
|
if (fleet !== undefined && !isAudience(fleet)) {
|
|
return { ok: false, status: 400, message: `"${fleet}" is not an audience. Choose one of: ${AUDIENCES.join(', ')}.` }
|
|
}
|
|
|
|
if (clanRoster !== undefined && !isClanAudience(clanRoster)) {
|
|
return {
|
|
ok: false,
|
|
status: 400,
|
|
message: `"${clanRoster}" is not a clan roster audience. Choose one of: ${CLAN_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 newsChanges = Object.entries(news || {})
|
|
for (const [id, value] of newsChanges) {
|
|
if (typeof value !== 'boolean') {
|
|
return { ok: false, status: 400, message: `News in game chat is on or off for server ${id}, not "${value}".` }
|
|
}
|
|
// 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)
|
|
if (clanRoster !== undefined) await db.setSetting(CLAN_ROSTER_KEY, clanRoster, userId)
|
|
for (const [id, value] of changes) {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await db.setServerPresence(id, value)
|
|
}
|
|
for (const [id, on] of newsChanges) {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await db.setServerNews(id, on)
|
|
}
|
|
|
|
// 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 } : {}),
|
|
...(clanRoster !== undefined ? { clanRoster } : {}),
|
|
servers: Object.fromEntries(changes.map(([id, value]) => [id, value === null ? 'inherit' : value])),
|
|
...(newsChanges.length ? { news: Object.fromEntries(newsChanges) } : {}),
|
|
},
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
AUDIENCES,
|
|
DEFAULT_PRESENCE,
|
|
PRESENCE_KEY,
|
|
isAudience,
|
|
CLAN_AUDIENCES,
|
|
DEFAULT_CLAN_ROSTER,
|
|
CLAN_ROSTER_KEY,
|
|
isClanAudience,
|
|
clanRosterAudience,
|
|
meets,
|
|
normalise,
|
|
viewerLevel,
|
|
fleetPresence,
|
|
presenceFor,
|
|
canSeePresence,
|
|
describe,
|
|
update,
|
|
}
|