fix(rust): nothing names who is online by default
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:
@@ -82,11 +82,39 @@ const STAFF_KINDS = Object.freeze([
|
||||
'perm.drift',
|
||||
])
|
||||
|
||||
/**
|
||||
* The public kinds that say a NAMED player was on the server at a given moment.
|
||||
*
|
||||
* A subset of `PUBLIC_KINDS`, not a third list: these are public-page material
|
||||
* whose audience an operator chooses (`model/visibility`), where the rest of
|
||||
* `PUBLIC_KINDS` is public by construction. The org lead's rule, settled
|
||||
* 2026-09-22: **nothing tells who is online by default** — the narrowest
|
||||
* audience (staff) unless an operator widens it, and a count is never a name.
|
||||
*
|
||||
* `player.death` and `player.chat` are here, and that was decided rather than
|
||||
* overlooked. They are the killfeed and the chat — the content a feed exists
|
||||
* for — and each one says "this person was on at 12:03" as plainly as a connect
|
||||
* frame does. `player.tally` is a per-minute flush that is only ever sent for a
|
||||
* player who is playing, which makes it a roll call with extra steps.
|
||||
*
|
||||
* What is left in the public set once these are removed is the server's own
|
||||
* story — a wipe, a start, a shutdown — which names nobody.
|
||||
*/
|
||||
const PRESENCE_KINDS = Object.freeze([
|
||||
'player.connected',
|
||||
'player.disconnected',
|
||||
'player.respawned',
|
||||
'player.death',
|
||||
'player.chat',
|
||||
'player.tally',
|
||||
])
|
||||
|
||||
/** Every kind protocol 3 defines. */
|
||||
const ALL_KINDS = Object.freeze([...PUBLIC_KINDS, ...STAFF_KINDS])
|
||||
|
||||
const PUBLIC = new Set(PUBLIC_KINDS)
|
||||
const STAFF = new Set(STAFF_KINDS)
|
||||
const PRESENCE = new Set(PRESENCE_KINDS)
|
||||
|
||||
/**
|
||||
* May a signed-out visitor see this kind?
|
||||
@@ -103,15 +131,27 @@ function isKnown(kind) {
|
||||
return PUBLIC.has(kind) || STAFF.has(kind)
|
||||
}
|
||||
|
||||
/** Does this kind name a player who was on the server at the time? */
|
||||
function isPresence(kind) {
|
||||
return PRESENCE.has(kind)
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrows a list of requested kinds to the ones a viewer may have.
|
||||
*
|
||||
* Returning the allowlist itself when nothing was requested is what makes the
|
||||
* public route safe by construction rather than by remembering to filter: there
|
||||
* is no code path where "no filter" means "everything".
|
||||
*
|
||||
* `presence` defaults to `false` for the same reason `admin` does: a caller that
|
||||
* forgets to say what the viewer may see gets the narrowest answer. The route
|
||||
* resolves it from the operator's setting (`model/visibility`); nothing else
|
||||
* should be passing `true`.
|
||||
*/
|
||||
function kindsFor({ admin = false, requested = null } = {}) {
|
||||
const permitted = admin ? ALL_KINDS : PUBLIC_KINDS
|
||||
function kindsFor({ admin = false, presence = false, requested = null } = {}) {
|
||||
const permitted = admin
|
||||
? ALL_KINDS
|
||||
: PUBLIC_KINDS.filter((k) => presence || !PRESENCE.has(k))
|
||||
|
||||
if (!requested || requested.length === 0) return [...permitted]
|
||||
|
||||
@@ -122,8 +162,10 @@ function kindsFor({ admin = false, requested = null } = {}) {
|
||||
module.exports = {
|
||||
PUBLIC_KINDS,
|
||||
STAFF_KINDS,
|
||||
PRESENCE_KINDS,
|
||||
ALL_KINDS,
|
||||
isPublic,
|
||||
isKnown,
|
||||
isPresence,
|
||||
kindsFor,
|
||||
}
|
||||
|
||||
@@ -86,6 +86,14 @@ module.exports = {
|
||||
// that needs an identity needs to *read* one.
|
||||
auth: { getUserFromRequest: (...args) => need().auth.getUserFromRequest(...args) },
|
||||
|
||||
// One user by id (MODULE_API.md §2.3, 1.1.0). Here for the presence gate
|
||||
// (`model/visibility`): `getUserFromRequest` decodes a token and nothing more,
|
||||
// so the role in it is the role the account had when the token was minted. A
|
||||
// moderator demoted this morning would keep reading who is online until their
|
||||
// token expired. Re-reading the row is what makes a demotion — or a ban — take
|
||||
// effect on the next request, the same promise core's admin tier makes.
|
||||
users: { getById: (...args) => need().users.getById(...args) },
|
||||
|
||||
// Core's middleware, taken as values rather than wrapped: express stores the
|
||||
// function reference at mount time, so a wrapper is what would end up in the
|
||||
// stack. Routers are built inside `register()`, so `ctx` is set by then.
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
-- registrant owned what.
|
||||
|
||||
-- Phase 7b.
|
||||
DROP TABLE IF EXISTS rust_settings;
|
||||
DROP TABLE IF EXISTS rust_config_writes;
|
||||
|
||||
-- Phase 7. Children before parents: every one of these carries a foreign key
|
||||
|
||||
@@ -668,3 +668,36 @@ CREATE TABLE IF NOT EXISTS rust_config_writes (
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL,
|
||||
KEY idx_rust_config_writes_server (server_id, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
|
||||
-- ── Who may see who is online (the presence fix, 2026-09-22) ──────────────
|
||||
--
|
||||
-- The org lead's rule: **nothing tells who is online by default.** The Online
|
||||
-- list, the killfeed, chat and every other frame that says a named player was on
|
||||
-- the server reach STAFF unless an operator deliberately widens them. A count is
|
||||
-- not a name and stays public.
|
||||
--
|
||||
-- Two places, because the decision has two shapes:
|
||||
--
|
||||
-- • `rust_settings` holds the FLEET default — one row per key. A key/value
|
||||
-- table rather than a column per setting, because phase 9's clan-roster
|
||||
-- audience is the next key and a table that grows a column per setting grows
|
||||
-- an ALTER per setting.
|
||||
-- • `rust_servers.presence_audience` is an optional PER-SERVER override. NULL
|
||||
-- means "inherit the fleet default", which is not the same as any audience —
|
||||
-- an operator who later narrows the fleet must narrow every server that never
|
||||
-- chose otherwise.
|
||||
--
|
||||
-- The stored value is a word (`staff` · `signed_in` · `public`) and an unknown
|
||||
-- word reads as `staff` (`model/visibility`): a typo in a row must narrow, never
|
||||
-- widen.
|
||||
CREATE TABLE IF NOT EXISTS rust_settings (
|
||||
setting_key VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
value VARCHAR(255) NOT NULL,
|
||||
updated_by INT NULL,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_rust_settings_user
|
||||
FOREIGN KEY (updated_by) REFERENCES users (id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
ALTER TABLE rust_servers ADD COLUMN IF NOT EXISTS presence_audience VARCHAR(16) NULL;
|
||||
|
||||
@@ -51,8 +51,8 @@ function parseKinds(raw) {
|
||||
* refused: naming it in an error would confirm the kind exists, which is a small
|
||||
* thing to leak and a free one to avoid.
|
||||
*/
|
||||
async function recent({ serverId, admin = false, kind = null, wipeId = null, limit }) {
|
||||
const kinds = catalogue.kindsFor({ admin, requested: parseKinds(kind) })
|
||||
async function recent({ serverId, admin = false, presence = false, kind = null, wipeId = null, limit }) {
|
||||
const kinds = catalogue.kindsFor({ admin, presence, requested: parseKinds(kind) })
|
||||
|
||||
// Every requested kind was refused. Answering with an empty list is right —
|
||||
// the events they asked for are, as far as they are concerned, not there.
|
||||
@@ -102,7 +102,7 @@ function shape(row) {
|
||||
* counters, so the two can never disagree — which is the whole reason R12's
|
||||
* "per-wipe detail plus all-time rollups" is one table and not two.
|
||||
*/
|
||||
async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit }) {
|
||||
async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit, presence = false }) {
|
||||
const rows = await db.leaderboard({
|
||||
serverId,
|
||||
wipeId,
|
||||
@@ -118,7 +118,11 @@ async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit }) {
|
||||
npcKills: Number(r.npcKills) || 0,
|
||||
structures: Number(r.structures) || 0,
|
||||
playtimeSec: Number(r.playtimeSec) || 0,
|
||||
lastSeen: r.lastSeen || null,
|
||||
// Withheld below the presence audience. A tally refreshes it every minute a
|
||||
// player is on, so a `lastSeen` of forty seconds ago is the Online tab by
|
||||
// another name. The ORDER still uses it as a tie-break — that says who was
|
||||
// on more recently, never whether anybody is on now.
|
||||
...(presence ? { lastSeen: r.lastSeen || null } : {}),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
55
server/model/visibility/visibility.db.js
Normal file
55
server/model/visibility/visibility.db.js
Normal file
@@ -0,0 +1,55 @@
|
||||
// ── SQL for the visibility settings ───────────────────────────────────────
|
||||
//
|
||||
// Two stores for one decision: the fleet default in `rust_settings`, and an
|
||||
// optional per-server override on `rust_servers`. See `schema.sql` for why each
|
||||
// lives where it does.
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const SETTINGS = 'rust_settings'
|
||||
const SERVERS = 'rust_servers'
|
||||
|
||||
/** One setting's stored value, or `null` when nobody has ever set it. */
|
||||
async function getSetting(key) {
|
||||
const rows = await core.query(`SELECT value FROM ${SETTINGS} WHERE setting_key = ?`, [key])
|
||||
return rows[0] ? rows[0].value : null
|
||||
}
|
||||
|
||||
async function setSetting(key, value, userId = null) {
|
||||
await core.query(
|
||||
`INSERT INTO ${SETTINGS} (setting_key, value, updated_by, updated_at)
|
||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON DUPLICATE KEY UPDATE value = VALUES(value), updated_by = VALUES(updated_by),
|
||||
updated_at = CURRENT_TIMESTAMP`,
|
||||
[key, value, userId],
|
||||
)
|
||||
}
|
||||
|
||||
/** One server's override, `null` for "inherit", or `undefined` when there is no such server. */
|
||||
async function getServerPresence(serverId) {
|
||||
const rows = await core.query(`SELECT presence_audience AS presence FROM ${SERVERS} WHERE id = ?`, [serverId])
|
||||
return rows[0] ? rows[0].presence : undefined
|
||||
}
|
||||
|
||||
/** Every configured server with its override, in the operator's own order. */
|
||||
async function listServerPresence() {
|
||||
return core.query(
|
||||
`SELECT id, name, enabled, presence_audience AS presence
|
||||
FROM ${SERVERS}
|
||||
ORDER BY sort_order ASC, id ASC`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets or clears (`null`) one server's override.
|
||||
*
|
||||
* Returns nothing, deliberately. `affectedRows` would look like a way to tell
|
||||
* "no such server" from success, and it is not one: without `foundRows` an
|
||||
* UPDATE writing the value already there reports 0, and whether core's pool sets
|
||||
* that flag is core's business. The model checks existence with a read first.
|
||||
*/
|
||||
async function setServerPresence(serverId, value) {
|
||||
await core.query(`UPDATE ${SERVERS} SET presence_audience = ? WHERE id = ?`, [value, serverId])
|
||||
}
|
||||
|
||||
module.exports = { getSetting, setSetting, getServerPresence, listServerPresence, setServerPresence }
|
||||
207
server/model/visibility/visibility.model.js
Normal file
207
server/model/visibility/visibility.model.js
Normal 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,
|
||||
}
|
||||
@@ -37,6 +37,11 @@ adminRustRouter.use('/permissions', require('./permissions.router'))
|
||||
// and this one edits the game host's own plugin settings.
|
||||
adminRustRouter.use('/config', require('./config.router'))
|
||||
|
||||
// Who may see who is online, under `/rust/visibility`. The org lead's rule is
|
||||
// that nothing names who is online by default; this is where an operator
|
||||
// deliberately widens it, fleet-wide or for one server.
|
||||
adminRustRouter.use('/visibility', require('./visibility.router'))
|
||||
|
||||
adminRustRouter.get(
|
||||
'/servers',
|
||||
// #swagger.tags = ['Admin · Rust']
|
||||
|
||||
39
server/router/admin/visibility.controller.js
Normal file
39
server/router/admin/visibility.controller.js
Normal file
@@ -0,0 +1,39 @@
|
||||
// ── Admin · Rust · Visibility — the handlers ──────────────────────────────
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const visibility = require('../../model/visibility/visibility.model')
|
||||
|
||||
const log = core.logger('visibility')
|
||||
|
||||
async function read(req, res) {
|
||||
try {
|
||||
res.json(await visibility.describe())
|
||||
} catch (err) {
|
||||
log.error('failed to read visibility settings', { error: err.message })
|
||||
res.status(500).json({ message: 'Failed to read the visibility settings' })
|
||||
}
|
||||
}
|
||||
|
||||
async function update(req, res) {
|
||||
try {
|
||||
const { fleet, servers } = req.body || {}
|
||||
const result = await visibility.update({ fleet, servers }, req.user)
|
||||
if (!result.ok) {
|
||||
res.status(result.status || 400).json({ message: result.message })
|
||||
return
|
||||
}
|
||||
|
||||
// One row per save, naming everything it changed. Widening who may see the
|
||||
// roll call is exactly the kind of change somebody later needs to trace to a
|
||||
// person and a time.
|
||||
await core.activity.log({ req, action: 'rust.visibility.save', detail: result.changed })
|
||||
|
||||
res.json(await visibility.describe())
|
||||
} catch (err) {
|
||||
log.error('failed to save visibility settings', { error: err.message })
|
||||
res.status(500).json({ message: 'Failed to save the visibility settings' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { read, update }
|
||||
51
server/router/admin/visibility.router.js
Normal file
51
server/router/admin/visibility.router.js
Normal file
@@ -0,0 +1,51 @@
|
||||
// ── Admin · Rust · Visibility ─────────────────────────────────────────────
|
||||
//
|
||||
// Mounted under the admin tier's `/rust` prefix, so every path here is
|
||||
// `/api/v1/admin/rust/visibility`. Who may see what the servers say about the
|
||||
// people on them — a fourth subject beside the bridge, the permissions and the
|
||||
// mod configuration.
|
||||
//
|
||||
// **Every route is `requireRole('admin')`.** The tier's own gate admits editors
|
||||
// and moderators, and a moderator widening the roll call to the public is the
|
||||
// decision the org lead settled should be deliberate. Reading is gated the same
|
||||
// as writing: the screen is one form, and a view of the settings without the
|
||||
// power to change them is not something anybody has asked for.
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const express = core.express
|
||||
const visibility = require('./visibility.controller')
|
||||
const { requireRole, validate } = core.middleware
|
||||
const { body } = core.validator
|
||||
|
||||
const visibilityRouter = express.Router()
|
||||
|
||||
const AUDIENCES = ['staff', 'signed_in', 'public']
|
||||
|
||||
visibilityRouter.get(
|
||||
'/',
|
||||
// #swagger.tags = ['Admin · Rust']
|
||||
// #swagger.summary = 'Who may see who is online'
|
||||
// #swagger.description = 'The fleet default and every server’s optional override. It governs the Online list, every feed item that names a player who was on the server (connects, respawns, deaths, chat, tallies) and the leaderboard’s `lastSeen`. The default is `staff`: nothing names who is online until an operator widens it. The player count is public at every setting.'
|
||||
/* #swagger.responses[200] = { description: 'The fleet default and each server', content: { "application/json": { schema: { $ref: "#/components/schemas/RustVisibility" } } } } */
|
||||
requireRole('admin'),
|
||||
visibility.read,
|
||||
)
|
||||
|
||||
visibilityRouter.put(
|
||||
'/',
|
||||
// #swagger.tags = ['Admin · Rust']
|
||||
// #swagger.summary = 'Change who may see who is online'
|
||||
// #swagger.description = 'Sets the fleet default, one or more server overrides, or both. A server set to `null` follows the fleet default again. Validated whole before anything is written: a request naming a server that does not exist changes nothing.'
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustVisibilityUpdate" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Saved; answers the new state', content: { "application/json": { schema: { $ref: "#/components/schemas/RustVisibility" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'An audience that does not exist' } */
|
||||
/* #swagger.responses[404] = { description: 'A server that does not exist' } */
|
||||
requireRole('admin'),
|
||||
body('fleet').optional().isIn(AUDIENCES).withMessage(`fleet must be one of ${AUDIENCES.join(', ')}`),
|
||||
body('servers').optional().isObject().withMessage('servers maps a server id to an audience or null'),
|
||||
validate,
|
||||
visibility.update,
|
||||
)
|
||||
|
||||
module.exports = visibilityRouter
|
||||
@@ -13,9 +13,24 @@ const core = require('../../core')
|
||||
|
||||
const events = require('../../model/events/events.model')
|
||||
const servers = require('../../model/servers/servers.model')
|
||||
const visibility = require('../../model/visibility/visibility.model')
|
||||
|
||||
const log = core.logger('public')
|
||||
|
||||
/**
|
||||
* Marks a response as depending on who asked.
|
||||
*
|
||||
* Three routes below answer differently for a moderator and for a stranger, and
|
||||
* a shared cache in front of the site that stored the moderator's answer would
|
||||
* hand the roll call to the next anonymous visitor. `private` keeps it out of
|
||||
* every cache but the viewer's own; `Vary` says why, for any cache that reads it.
|
||||
*/
|
||||
function perViewer(res) {
|
||||
res.set('Cache-Control', 'private, no-store')
|
||||
res.vary('Cookie')
|
||||
res.vary('Authorization')
|
||||
}
|
||||
|
||||
async function listServers(req, res) {
|
||||
try {
|
||||
res.json({ servers: await servers.listPublic() })
|
||||
@@ -56,16 +71,26 @@ async function getServer(req, res) {
|
||||
* handler.** `events.recent` takes the viewer explicitly and defaults to the
|
||||
* public allowlist, so the way to leak an IP address from here is to add an
|
||||
* argument rather than to forget one.
|
||||
*
|
||||
* `presence` is resolved per request from the operator's setting. Below it, the
|
||||
* feed carries only what names nobody — a wipe, a start, a shutdown — and says
|
||||
* so with `presenceHidden`, so a page can explain a quiet feed instead of
|
||||
* implying a quiet server.
|
||||
*/
|
||||
async function listEvents(req, res) {
|
||||
try {
|
||||
const presence = await visibility.canSeePresence(req, req.params.id)
|
||||
perViewer(res)
|
||||
res.json({
|
||||
events: await events.recent({
|
||||
serverId: req.params.id,
|
||||
presence: presence.visible,
|
||||
kind: req.query.kind,
|
||||
wipeId: req.query.wipe || null,
|
||||
limit: req.query.limit,
|
||||
}),
|
||||
presenceHidden: !presence.visible,
|
||||
presenceAudience: presence.required,
|
||||
})
|
||||
} catch (err) {
|
||||
log.error('failed to read events', { server: req.params.id, error: err.message })
|
||||
@@ -75,12 +100,15 @@ async function listEvents(req, res) {
|
||||
|
||||
async function listLeaderboard(req, res) {
|
||||
try {
|
||||
const presence = await visibility.canSeePresence(req, req.params.id)
|
||||
perViewer(res)
|
||||
res.json({
|
||||
leaderboard: await events.leaderboard({
|
||||
serverId: req.params.id,
|
||||
wipeId: req.query.wipe || null,
|
||||
sort: req.query.sort,
|
||||
limit: req.query.limit,
|
||||
presence: presence.visible,
|
||||
}),
|
||||
})
|
||||
} catch (err) {
|
||||
@@ -98,9 +126,34 @@ async function listWipes(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Who is on the server right now — or, below the operator's audience, how many.
|
||||
*
|
||||
* The count stays public: it is already on the server list and in the footer,
|
||||
* and a number names nobody. The names do not, by default (the org lead's rule,
|
||||
* `model/visibility`). A hidden answer is still a 200 with the same shape — an
|
||||
* empty `players` array — plus `hidden` and `count`, so a client that predates
|
||||
* the flag renders an empty list rather than breaking, and a current one can say
|
||||
* "12 online" instead of "nobody".
|
||||
*/
|
||||
async function listOnline(req, res) {
|
||||
try {
|
||||
res.json({ players: await events.online(req.params.id) })
|
||||
const presence = await visibility.canSeePresence(req, req.params.id)
|
||||
perViewer(res)
|
||||
|
||||
if (!presence.visible) {
|
||||
const server = await servers.getPublic(req.params.id)
|
||||
res.json({
|
||||
players: [],
|
||||
hidden: true,
|
||||
count: server ? server.players : 0,
|
||||
audience: presence.required,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const players = await events.online(req.params.id)
|
||||
res.json({ players, hidden: false, count: players.length, audience: presence.required })
|
||||
} catch (err) {
|
||||
log.error('failed to read presence', { server: req.params.id, error: err.message })
|
||||
res.status(500).json({ message: 'Failed to read who is online' })
|
||||
|
||||
@@ -68,7 +68,7 @@ rustRouter.get(
|
||||
'/servers/:id/events',
|
||||
// #swagger.tags = ['Public · Rust']
|
||||
// #swagger.summary = 'Recent events on one Rust server'
|
||||
// #swagger.description = 'The killfeed and everything else public that happened on a server, newest first. Narrow with `kind` (comma-separated) and `wipe`. Only publicly classified kinds are ever returned — moderation events, login attempts and anything carrying an IP address are stored but never served here.'
|
||||
// #swagger.description = 'The killfeed and everything else public that happened on a server, newest first. Narrow with `kind` (comma-separated) and `wipe`. Only publicly classified kinds are ever returned — moderation events, login attempts and anything carrying an IP address are stored but never served here. Kinds that name a player who was on the server (connects, respawns, deaths, chat, tallies) are served only to viewers inside the operator’s presence audience, which defaults to staff; `presenceHidden` says when they were withheld.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
|
||||
// #swagger.parameters['kind'] = { in: 'query', required: false, description: 'One kind, or several comma-separated', schema: { type: 'string' } }
|
||||
// #swagger.parameters['wipe'] = { in: 'query', required: false, description: 'Restrict to one wipe id', schema: { type: 'string' } }
|
||||
@@ -82,7 +82,7 @@ rustRouter.get(
|
||||
'/servers/:id/leaderboard',
|
||||
// #swagger.tags = ['Public · Rust']
|
||||
// #swagger.summary = 'The leaderboard for one Rust server'
|
||||
// #swagger.description = 'Per-wipe when `wipe` is given, all-time otherwise. All-time is the per-wipe rows summed rather than a second set of counters, so a wipe splits a player’s history without ending it.'
|
||||
// #swagger.description = 'Per-wipe when `wipe` is given, all-time otherwise. All-time is the per-wipe rows summed rather than a second set of counters, so a wipe splits a player’s history without ending it. `lastSeen` is withheld below the operator’s presence audience: a gather tally refreshes it every minute a player is on, so it would name who is online.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
|
||||
// #swagger.parameters['wipe'] = { in: 'query', required: false, description: 'Restrict to one wipe id', schema: { type: 'string' } }
|
||||
// #swagger.parameters['sort'] = { in: 'query', required: false, description: 'kills, deaths, npcKills or playtime', schema: { type: 'string' } }
|
||||
@@ -107,9 +107,9 @@ rustRouter.get(
|
||||
'/servers/:id/online',
|
||||
// #swagger.tags = ['Public · Rust']
|
||||
// #swagger.summary = 'Who is on one Rust server right now'
|
||||
// #swagger.description = 'Read from the presence board the bridge re-sends on every connect and every minute, rather than counted from connect and disconnect events — so it is correct even after the website has missed one.'
|
||||
// #swagger.description = 'Read from the presence board the bridge re-sends on every connect and every minute, rather than counted from connect and disconnect events — so it is correct even after the website has missed one. **Nothing names who is online by default**: below the operator’s presence audience (staff unless widened) the names are withheld and only `count` is answered.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
|
||||
/* #swagger.responses[200] = { description: 'Who is online' } */
|
||||
/* #swagger.responses[200] = { description: 'Who is online — or, below the operator’s presence audience, only how many', content: { "application/json": { schema: { $ref: "#/components/schemas/RustOnline" } } } } */
|
||||
siteMode,
|
||||
servers.listOnline,
|
||||
)
|
||||
|
||||
@@ -478,6 +478,77 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
},
|
||||
RustOnline: {
|
||||
type: 'object',
|
||||
description: 'Who is on one server (GET /public/rust/servers/{id}/online). Below the operator’s presence audience the names are withheld and only the count is answered — nothing names who is online by default.',
|
||||
properties: {
|
||||
players: {
|
||||
type: 'array',
|
||||
description: 'Empty whenever `hidden` is true.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
steamId: { type: 'string', example: '76561198000000000' },
|
||||
name: { type: 'string', nullable: true, example: 'Wanderer' },
|
||||
sleeping: { type: 'boolean', example: false },
|
||||
connectedAt: { type: 'string', nullable: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
hidden: { type: 'boolean', description: 'Were the names withheld from this viewer?', example: true },
|
||||
count: { type: 'integer', description: 'How many are online. Public at every audience.', example: 12 },
|
||||
audience: { $ref: '#/components/schemas/RustAudience' },
|
||||
},
|
||||
},
|
||||
RustAudience: {
|
||||
type: 'string',
|
||||
enum: ['staff', 'signed_in', 'public'],
|
||||
description: 'Who may see something: admins and moderators, any signed-in account, or anybody. Ordered — each includes the ones before it.',
|
||||
example: 'staff',
|
||||
},
|
||||
RustVisibility: {
|
||||
type: 'object',
|
||||
description: 'Who may see who is online: the fleet default and each server’s optional override (GET /admin/rust/visibility).',
|
||||
properties: {
|
||||
audiences: { type: 'array', items: { $ref: '#/components/schemas/RustAudience' } },
|
||||
presence: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
fleet: { $ref: '#/components/schemas/RustAudience' },
|
||||
servers: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', example: 'main' },
|
||||
name: { type: 'string', example: 'Main · Vanilla' },
|
||||
enabled: { type: 'boolean', example: true },
|
||||
override: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
enum: ['staff', 'signed_in', 'public', null],
|
||||
description: 'This server’s own choice, or null to follow the fleet default.',
|
||||
},
|
||||
effective: { $ref: '#/components/schemas/RustAudience' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
RustVisibilityUpdate: {
|
||||
type: 'object',
|
||||
description: 'A change to who may see who is online. Either part may be omitted; a server set to null follows the fleet default again.',
|
||||
properties: {
|
||||
fleet: { $ref: '#/components/schemas/RustAudience' },
|
||||
servers: {
|
||||
type: 'object',
|
||||
additionalProperties: { type: 'string', nullable: true, enum: ['staff', 'signed_in', 'public', null] },
|
||||
example: { main: 'public', pvp: null },
|
||||
},
|
||||
},
|
||||
},
|
||||
RustSidecarProbe: {
|
||||
type: 'object',
|
||||
description: 'What a sidecar said when probed (POST /admin/rust/servers/{id}/test).',
|
||||
|
||||
@@ -53,6 +53,9 @@ function fakeCtx(overrides = {}) {
|
||||
return log
|
||||
},
|
||||
auth: { getUserFromRequest: spy(null) },
|
||||
// One user by id. Null by default — an anonymous suite resolves nobody —
|
||||
// and a test that needs a viewer installs its own.
|
||||
users: { getById: spy(Promise.resolve(null)) },
|
||||
// The engagement seam (§2.3). One method, recording, because that is the
|
||||
// whole of what a module may do with it: fire a declared event and stop.
|
||||
// Core's own emit is fire-and-forget and returns nothing, so this does too —
|
||||
|
||||
@@ -51,7 +51,12 @@ test('a viewer with no kinds asked for gets the allowlist, never everything', ()
|
||||
const asPublic = catalogue.kindsFor({})
|
||||
const asAdmin = catalogue.kindsFor({ admin: true })
|
||||
|
||||
assert.deepEqual(asPublic, [...catalogue.PUBLIC_KINDS])
|
||||
// The public view with nothing said about presence is the kinds that name
|
||||
// nobody — a wipe, a start, a shutdown.
|
||||
assert.deepEqual(
|
||||
asPublic,
|
||||
catalogue.PUBLIC_KINDS.filter((k) => !catalogue.PRESENCE_KINDS.includes(k)),
|
||||
)
|
||||
assert.equal(asAdmin.length, catalogue.ALL_KINDS.length)
|
||||
|
||||
// The property that makes the route safe by construction: there is no argument
|
||||
@@ -61,10 +66,13 @@ test('a viewer with no kinds asked for gets the allowlist, never everything', ()
|
||||
})
|
||||
|
||||
test('a kind a viewer may not see is dropped, not refused', () => {
|
||||
const asked = catalogue.kindsFor({ requested: ['player.death', 'player.banned'] })
|
||||
const asked = catalogue.kindsFor({ presence: true, requested: ['player.death', 'player.banned'] })
|
||||
|
||||
assert.deepEqual(asked, ['player.death'])
|
||||
|
||||
// Without the presence audience a death is dropped too.
|
||||
assert.deepEqual(catalogue.kindsFor({ requested: ['player.death', 'server.wipe'] }), ['server.wipe'])
|
||||
|
||||
// Asking for only forbidden kinds answers with nothing to select, which the
|
||||
// model turns into an empty list — the events are, as far as this viewer is
|
||||
// concerned, not there.
|
||||
@@ -116,3 +124,26 @@ test('the classification covers exactly the kinds protocol 4 defines', () => {
|
||||
|
||||
assert.deepEqual([...catalogue.ALL_KINDS].sort(), [...PROTOCOL_4].sort())
|
||||
})
|
||||
|
||||
test('every kind that names a player who was on is behind the presence setting', () => {
|
||||
// The org lead's rule (2026-09-22): nothing tells who is online by default.
|
||||
// Each of these says a named player was on the server at a given moment.
|
||||
for (const kind of [
|
||||
'player.connected',
|
||||
'player.disconnected',
|
||||
'player.respawned',
|
||||
'player.death',
|
||||
'player.chat',
|
||||
'player.tally',
|
||||
]) {
|
||||
assert.ok(catalogue.isPresence(kind), `${kind} must be gated as presence`)
|
||||
assert.ok(!catalogue.kindsFor({}).includes(kind), `${kind} must not reach a default public view`)
|
||||
assert.ok(catalogue.kindsFor({ presence: true }).includes(kind))
|
||||
}
|
||||
|
||||
// A presence kind is a subset of the public ones, never a staff kind widened.
|
||||
for (const kind of catalogue.PRESENCE_KINDS) assert.ok(catalogue.PUBLIC_KINDS.includes(kind))
|
||||
|
||||
// And what is left names nobody.
|
||||
assert.deepEqual(catalogue.kindsFor({}).sort(), ['server.initialized', 'server.shutdown', 'server.wipe'])
|
||||
})
|
||||
|
||||
@@ -60,7 +60,14 @@ test('a reader who does not say who they are gets the public view', async () =>
|
||||
await model.recent({ serverId: 'main' })
|
||||
|
||||
assert.ok(!asked.kinds.includes('player.banned'), 'no IP-carrying kind by default')
|
||||
assert.ok(asked.kinds.includes('player.death'))
|
||||
// Nor anything naming a player who was on — the org lead's rule, and a caller
|
||||
// that forgets to say what the viewer may see gets the narrowest answer.
|
||||
assert.ok(!asked.kinds.includes('player.death'), 'no presence kind by default')
|
||||
assert.ok(asked.kinds.includes('server.wipe'))
|
||||
|
||||
await model.recent({ serverId: 'main', presence: true })
|
||||
assert.ok(asked.kinds.includes('player.death'), 'a viewer inside the presence audience gets the killfeed')
|
||||
assert.ok(!asked.kinds.includes('player.banned'), 'presence never widens to staff kinds')
|
||||
|
||||
await model.recent({ serverId: 'main', admin: true })
|
||||
assert.ok(asked.kinds.includes('player.banned'), 'an admin who says so gets them')
|
||||
@@ -142,3 +149,24 @@ test('the leaderboard answers numbers, never nulls', async () => {
|
||||
db.leaderboard = original
|
||||
}
|
||||
})
|
||||
|
||||
test('the leaderboard withholds lastSeen unless the viewer may see who is online', async () => {
|
||||
withCore()
|
||||
|
||||
const db = require('../model/events/events.db')
|
||||
const model = require('../model/events/events.model')
|
||||
const original = db.leaderboard
|
||||
|
||||
db.leaderboard = async () => [{ steamId: '7656', name: 'A', kills: 3, lastSeen: '2026-09-22T10:00:00Z' }]
|
||||
|
||||
try {
|
||||
const hidden = await model.leaderboard({ serverId: 'main' })
|
||||
assert.equal('lastSeen' in hidden[0], false, 'absent, not null — null would read as "never seen"')
|
||||
assert.equal(hidden[0].kills, 3)
|
||||
|
||||
const shown = await model.leaderboard({ serverId: 'main', presence: true })
|
||||
assert.equal(shown[0].lastSeen, '2026-09-22T10:00:00Z')
|
||||
} finally {
|
||||
db.leaderboard = original
|
||||
}
|
||||
})
|
||||
|
||||
262
server/test/visibility.test.js
Normal file
262
server/test/visibility.test.js
Normal file
@@ -0,0 +1,262 @@
|
||||
// ── Who may see who is online ─────────────────────────────────────────────
|
||||
//
|
||||
// The org lead's rule (2026-09-22): nothing tells who is online by default. The
|
||||
// suite holds the four properties that make that rule true rather than merely
|
||||
// intended:
|
||||
//
|
||||
// • an install nobody has configured answers STAFF;
|
||||
// • the viewer's standing comes from the ROW, not the token — a demotion or a
|
||||
// ban takes effect on the next request;
|
||||
// • anything unrecognised or unanswerable narrows, never widens;
|
||||
// • the public routes answer the count and withhold the names.
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
|
||||
const { fakeCtx, spy } = require('./_fakes')
|
||||
|
||||
/**
|
||||
* The model with a stubbed db and a chosen viewer.
|
||||
*
|
||||
* `claimed` is what the token says; `row` is what the users table says now.
|
||||
*/
|
||||
function setup({ fleet = null, overrides = {}, claimed = null, row = null, usersThrow = false } = {}) {
|
||||
require('../core')._reset()
|
||||
require('../core').init(
|
||||
fakeCtx({
|
||||
auth: { getUserFromRequest: () => claimed },
|
||||
users: {
|
||||
getById: async () => {
|
||||
if (usersThrow) throw new Error('pool exhausted')
|
||||
return row
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const db = require('../model/visibility/visibility.db')
|
||||
const model = require('../model/visibility/visibility.model')
|
||||
|
||||
const written = { settings: [], servers: [] }
|
||||
const originals = { ...db }
|
||||
db.getSetting = async () => fleet
|
||||
db.setSetting = async (key, value, userId) => written.settings.push({ key, value, userId })
|
||||
db.getServerPresence = async (id) => (id in overrides ? overrides[id] : undefined)
|
||||
db.listServerPresence = async () =>
|
||||
Object.entries(overrides).map(([id, presence]) => ({ id, name: id.toUpperCase(), enabled: 1, presence }))
|
||||
db.setServerPresence = async (id, value) => written.servers.push({ id, value })
|
||||
|
||||
return { model, written, restore: () => Object.assign(db, originals) }
|
||||
}
|
||||
|
||||
test('an install nobody has configured shows the roll call to staff and nobody else', async () => {
|
||||
const { model, restore } = setup({ overrides: { main: null } })
|
||||
try {
|
||||
assert.equal(await model.fleetPresence(), 'staff')
|
||||
assert.equal(await model.presenceFor('main'), 'staff')
|
||||
assert.equal((await model.canSeePresence({}, 'main')).visible, false, 'anonymous')
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
})
|
||||
|
||||
test('the standing comes from the row, not the token', async () => {
|
||||
// The token says moderator; the row says they were demoted this morning.
|
||||
const demoted = setup({ claimed: { id: 4, role: 'moderator' }, row: { id: 4, role: 'player', status: 'active' } })
|
||||
try {
|
||||
assert.equal(await demoted.model.viewerLevel({}), 'signed_in')
|
||||
} finally {
|
||||
demoted.restore()
|
||||
}
|
||||
|
||||
// The token says admin; the account has been banned since.
|
||||
const banned = setup({ claimed: { id: 4, role: 'admin' }, row: { id: 4, role: 'admin', status: 'banned' } })
|
||||
try {
|
||||
assert.equal(await banned.model.viewerLevel({}), 'public')
|
||||
} finally {
|
||||
banned.restore()
|
||||
}
|
||||
|
||||
const moderator = setup({ claimed: { id: 5 }, row: { id: 5, role: 'moderator', status: 'active' } })
|
||||
try {
|
||||
assert.equal(await moderator.model.viewerLevel({}), 'staff')
|
||||
} finally {
|
||||
moderator.restore()
|
||||
}
|
||||
})
|
||||
|
||||
test('a viewer who cannot be resolved is anonymous', async () => {
|
||||
const gone = setup({ claimed: { id: 9 }, row: null })
|
||||
try {
|
||||
assert.equal(await gone.model.viewerLevel({}), 'public')
|
||||
} finally {
|
||||
gone.restore()
|
||||
}
|
||||
|
||||
const failing = setup({ claimed: { id: 9 }, usersThrow: true })
|
||||
try {
|
||||
assert.equal(await failing.model.viewerLevel({}), 'public')
|
||||
} finally {
|
||||
failing.restore()
|
||||
}
|
||||
})
|
||||
|
||||
test('a stored value this build does not recognise narrows to staff', async () => {
|
||||
const { model, restore } = setup({ fleet: 'everyone', overrides: { main: 'PUBLIC', pvp: null } })
|
||||
try {
|
||||
assert.equal(await model.fleetPresence(), 'staff')
|
||||
assert.equal(await model.presenceFor('main'), 'staff', 'a mis-cased word is not "public"')
|
||||
assert.equal(await model.presenceFor('pvp'), 'staff', 'inherits the (narrowed) fleet default')
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
})
|
||||
|
||||
test('a server override wins over the fleet, and null inherits it', async () => {
|
||||
const { model, restore } = setup({
|
||||
fleet: 'signed_in',
|
||||
overrides: { main: 'public', pvp: 'staff', creative: null },
|
||||
claimed: { id: 4 },
|
||||
row: { id: 4, role: 'player', status: 'active' },
|
||||
})
|
||||
try {
|
||||
assert.equal(await model.presenceFor('main'), 'public')
|
||||
assert.equal(await model.presenceFor('pvp'), 'staff')
|
||||
assert.equal(await model.presenceFor('creative'), 'signed_in')
|
||||
|
||||
// A signed-in player sees main and creative, not pvp.
|
||||
assert.equal((await model.canSeePresence({}, 'main')).visible, true)
|
||||
assert.equal((await model.canSeePresence({}, 'creative')).visible, true)
|
||||
assert.equal((await model.canSeePresence({}, 'pvp')).visible, false)
|
||||
|
||||
const described = await model.describe()
|
||||
const byId = Object.fromEntries(described.presence.servers.map((s) => [s.id, s]))
|
||||
assert.equal(byId.creative.override, null)
|
||||
assert.equal(byId.creative.effective, 'signed_in')
|
||||
assert.equal(byId.pvp.effective, 'staff')
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
})
|
||||
|
||||
test('an update naming an unknown audience or server writes nothing at all', async () => {
|
||||
const { model, written, restore } = setup({ overrides: { main: null } })
|
||||
try {
|
||||
const badAudience = await model.update({ fleet: 'public', servers: { main: 'everyone' } })
|
||||
assert.equal(badAudience.ok, false)
|
||||
assert.equal(badAudience.status, 400)
|
||||
|
||||
const badServer = await model.update({ fleet: 'public', servers: { main: 'public', nope: 'public' } })
|
||||
assert.equal(badServer.ok, false)
|
||||
assert.equal(badServer.status, 404)
|
||||
assert.match(badServer.message, /nope/)
|
||||
|
||||
assert.deepEqual(written, { settings: [], servers: [] }, 'validated whole before anything was written')
|
||||
|
||||
const ok = await model.update({ fleet: 'signed_in', servers: { main: null } }, { id: 1 })
|
||||
assert.equal(ok.ok, true)
|
||||
assert.deepEqual(written.settings, [{ key: 'presence.audience', value: 'signed_in', userId: 1 }])
|
||||
assert.deepEqual(written.servers, [{ id: 'main', value: null }])
|
||||
assert.deepEqual(ok.changed, { fleet: 'signed_in', servers: { main: 'inherit' } })
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
})
|
||||
|
||||
// ── The public routes ─────────────────────────────────────────────────────
|
||||
|
||||
/** A response double recording what a handler answered. */
|
||||
function fakeRes() {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
varied: [],
|
||||
body: undefined,
|
||||
status(code) { this.statusCode = code; return this },
|
||||
json(body) { this.body = body; return this },
|
||||
set(name, value) { this.headers[name.toLowerCase()] = value; return this },
|
||||
vary(name) { this.varied.push(name); return this },
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
function withPresence(visible) {
|
||||
const visibility = require('../model/visibility/visibility.model')
|
||||
const events = require('../model/events/events.model')
|
||||
const servers = require('../model/servers/servers.model')
|
||||
const originals = {
|
||||
canSeePresence: visibility.canSeePresence,
|
||||
online: events.online,
|
||||
getPublic: servers.getPublic,
|
||||
}
|
||||
visibility.canSeePresence = async () => ({ visible, level: visible ? 'staff' : 'public', required: 'staff' })
|
||||
events.online = spy(Promise.resolve([{ steamId: '7656', name: 'Wanderer', sleeping: false, connectedAt: null }]))
|
||||
servers.getPublic = async () => ({ id: 'main', players: 12 })
|
||||
return {
|
||||
events,
|
||||
restore: () => {
|
||||
visibility.canSeePresence = originals.canSeePresence
|
||||
events.online = originals.online
|
||||
servers.getPublic = originals.getPublic
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
test('below the audience, the Online list answers the count and never reads the names', async () => {
|
||||
require('../core')._reset()
|
||||
require('../core').init(fakeCtx())
|
||||
const { events, restore } = withPresence(false)
|
||||
try {
|
||||
const controller = require('../router/public/rust.controller')
|
||||
const res = fakeRes()
|
||||
await controller.listOnline({ params: { id: 'main' } }, res)
|
||||
|
||||
assert.deepEqual(res.body, { players: [], hidden: true, count: 12, audience: 'staff' })
|
||||
assert.equal(events.online.calls.length, 0, 'the names are not even read')
|
||||
assert.equal(res.headers['cache-control'], 'private, no-store', 'a per-viewer answer must not be shared by a cache')
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
})
|
||||
|
||||
test('inside the audience, the Online list names the players', async () => {
|
||||
require('../core')._reset()
|
||||
require('../core').init(fakeCtx())
|
||||
const { restore } = withPresence(true)
|
||||
try {
|
||||
const controller = require('../router/public/rust.controller')
|
||||
const res = fakeRes()
|
||||
await controller.listOnline({ params: { id: 'main' } }, res)
|
||||
|
||||
assert.equal(res.body.hidden, false)
|
||||
assert.equal(res.body.players[0].name, 'Wanderer')
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
})
|
||||
|
||||
test('below the audience, the feed says it withheld the players rather than implying a quiet server', async () => {
|
||||
require('../core')._reset()
|
||||
require('../core').init(fakeCtx())
|
||||
const { restore } = withPresence(false)
|
||||
const events = require('../model/events/events.model')
|
||||
const original = events.recent
|
||||
let asked = null
|
||||
events.recent = async (args) => {
|
||||
asked = args
|
||||
return []
|
||||
}
|
||||
try {
|
||||
const controller = require('../router/public/rust.controller')
|
||||
const res = fakeRes()
|
||||
await controller.listEvents({ params: { id: 'main' }, query: {} }, res)
|
||||
|
||||
assert.equal(asked.presence, false)
|
||||
assert.equal(asked.admin, undefined, 'the public route never passes admin')
|
||||
assert.equal(res.body.presenceHidden, true)
|
||||
assert.equal(res.body.presenceAudience, 'staff')
|
||||
} finally {
|
||||
events.recent = original
|
||||
restore()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user