A first-party Rust clan is a Team (R5). This module becomes the site's Team provider and answers core from the plugin's `clans` board. Design of record: docs/modules/rust/PLAN.md §24, D47-D58. - The store: rust_clans, rust_clan_members and rust_clan_boards. A clan's identity is <serverId>:<clanId>:<createdMs> (D52), because the game restarts clan ids whenever its clan database version changes. - The provider (D53): getTeams is complete only when every server's board is fresh, supported and untruncated. It is partial when some are, and refuses when none are. Freshness is judged by the website's clock, from when the board's `t` last advanced. - Only a complete board may mark a clan gone. A board at the game's 100-clan ceiling (D55), or one with an unreadable row, proves nothing about what it leaves out. - Leadership is diffed board to board and published (D54). The five clan events are published as team.* kinds, and written to the Team feed as members-only lines (D49). - Core only writes feed items for a Team it already holds. So the last 10 minutes of clan events are re-offered on each board refresh, deduped by a sha1 key: core clamps a dedupeKey to 40 characters, and a readable key would be truncated into collisions. - projectRoster and the clan page share one audience rule (D48): the clan's linked members and staff by default, re-read from the users row. The setting lives on Admin > Rust visibility, which also warns about uMod Clans (D47) and the ceiling. - Public: GET servers/:id/clans (the list is public, D58) and GET clans/:externalId. The client adds a Clans tab and /rust/clans/:externalId, with three module slots for core's notify, activity and forum contributions (D56). - Linking and unlinking an account ask core to reconcile Teams (D57). - The clan kinds are staff-class in the public feed allowlist. - PROTOCOL_VERSION is now 6. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
265 lines
10 KiB
JavaScript
265 lines
10 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,
|
|
}
|
|
}),
|
|
},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 } = {}, 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 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)
|
|
}
|
|
|
|
// 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])),
|
|
},
|
|
}
|
|
}
|
|
|
|
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,
|
|
}
|