Files
Module-Rust/server/swagger/doc.js
wtclaude be44839896
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
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
2026-09-23 00:30:08 -05:00

580 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── The OpenAPI fragment: the shared half ─────────────────────────────────
//
// The tags and component schemas the `#swagger.*` annotations refer to.
// `scripts/swaggerFragment.js` feeds this to swagger-autogen; the per-endpoint
// detail lives beside each route, exactly as it does in core.
//
// **Two rules about names, and both belong to the MERGED document rather than to
// this file** (MODULE_API.md §6.1a). Core merges every started module's fragment
// over its own committed spec and serves the result at `/api/docs.json`, and core
// wins any key collision:
//
// • **Namespace what you DEFINE.** `RustServerList`, not `ServerList`. A second
// game's module describing the same idea under the same bare name would
// silently clobber this one or be clobbered by it.
// • **Reference what CORE defines by core's name.** `#/components/schemas/Error`
// and `ValidationError` are core's; point at them and do not redefine them.
//
// **swagger-autogen renders `components.schemas` from an EXAMPLE object, not from
// raw OpenAPI.** `{ type: 'object' }` comes back as a meta-description of itself.
// That is uniform across core's committed spec and is the house shape.
module.exports = {
tags: [
{
name: 'Public · Rust',
description: 'The Rust servers this site follows, as each one last reported itself',
},
{
name: 'Player · Rust',
description: 'The Rust surface for a signed-in player',
},
{
name: 'Admin · Rust',
description: 'Configuring the Rust servers and their sidecars',
},
],
components: {
schemas: {
RustServerList: {
type: 'object',
description: 'Every Rust server this site follows (GET /public/rust/servers).',
properties: {
servers: {
type: 'array',
items: { $ref: '#/components/schemas/RustServer' },
},
},
},
RustServer: {
type: 'object',
description: 'One Rust server, as it last reported itself.',
properties: {
id: { type: 'string', example: 'main' },
name: { type: 'string', example: 'Main · Vanilla' },
online: { type: 'boolean', example: true },
players: { type: 'integer', example: 42 },
maxPlayers: { type: 'integer', example: 100 },
hostname: { type: 'string', nullable: true, example: 'Runic Gateway · Main' },
level: { type: 'string', nullable: true, example: 'Procedural Map' },
worldSize: { type: 'integer', nullable: true, example: 4000 },
seed: { type: 'integer', nullable: true, example: 1234 },
updatedAt: { type: 'string', format: 'date-time', nullable: true },
stale: {
type: 'boolean',
description: 'Has nothing reported in longer than the freshness window? A stale row is reported offline.',
example: false,
},
},
},
RustAdminServerList: {
type: 'object',
description: 'The configured servers, with their sidecar settings (GET /admin/rust/servers).',
properties: {
servers: {
type: 'array',
items: { $ref: '#/components/schemas/RustAdminServer' },
},
},
},
RustAdminServer: {
type: 'object',
description: 'One configured server. The sidecar token is never included — `hasToken` reports only whether one is stored.',
properties: {
id: { type: 'string', example: 'main' },
name: { type: 'string', example: 'Main · Vanilla' },
sidecarBaseUrl: { type: 'string', example: 'http://10.0.0.5:8090' },
hasToken: { type: 'boolean', example: true },
protocol: { type: 'integer', example: 1 },
enabled: { type: 'boolean', example: true },
sortOrder: { type: 'integer', example: 0 },
reachable: {
type: 'boolean',
description: 'Did the sidecar answer on the last poll? Separate from `online`, which is about the game rather than the bridge.',
example: true,
},
bootId: { type: 'string', nullable: true, example: 'boot-20260915T194502Z' },
sidecarProtocol: { type: 'integer', nullable: true, example: 1 },
online: { type: 'boolean', example: true },
players: { type: 'integer', example: 42 },
stale: { type: 'boolean', example: false },
},
},
RustLink: {
type: 'object',
description: 'One Steam account linked to a website user. Never carries a code.',
properties: {
steamId: { type: 'string', example: '76561198000000000' },
name: {
type: 'string',
nullable: true,
description: 'What the player was called in game when they linked. A display name only — a Rust name changes on a whim and nothing identifies anybody by it.',
example: 'Wanderer',
},
serverId: {
type: 'string',
nullable: true,
description: 'Which server minted the code. Not part of the identity — a link is fleet-wide — but it is where a support conversation starts.',
example: 'main',
},
linkedAt: { type: 'string', format: 'date-time' },
},
},
RustLinkList: {
type: 'object',
description: 'The Steam accounts one website user holds (GET /player/rust/links).',
properties: {
links: { type: 'array', items: { $ref: '#/components/schemas/RustLink' } },
},
},
RustPlayerReach: {
type: 'object',
description: 'One server an entitlements scope reaches, and whether it is there yet.',
properties: {
id: { type: 'string', example: 'main' },
name: { type: 'string', example: 'Main · Vanilla+' },
live: {
type: 'boolean',
description: 'True only when a sync confirmed this into that servers own store. False covers every way it has not arrived — the server is offline, no loaded plugin registered the name, or its store has never seen the account — and the difference between those is an operators diagnosis, not a players.',
example: true,
},
},
},
RustPlayerPermissions: {
type: 'object',
description: 'What the site has given the signed-in player in game (GET /player/rust/permissions).',
properties: {
accounts: {
type: 'integer',
description: 'How many Steam accounts the caller has linked. Zero is why an entitlement can be authored and reach nobody.',
example: 1,
},
groups: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string', example: 'vip' },
title: { type: 'string', example: 'VIP' },
scope: { type: 'string', description: 'A server id, or `*` for the whole fleet.', example: '*' },
since: { type: 'string', format: 'date-time' },
permissions: { type: 'array', items: { type: 'string' }, example: ['kits.vip'] },
reach: { type: 'array', items: { $ref: '#/components/schemas/RustPlayerReach' } },
},
},
},
grants: {
type: 'array',
items: {
type: 'object',
properties: {
permission: { type: 'string', example: 'kits.vip' },
scope: { type: 'string', example: '*' },
source: { type: 'string', description: 'Who authored it — `admin` now, an event action later.', example: 'admin' },
note: { type: 'string', nullable: true },
since: { type: 'string', format: 'date-time' },
reach: { type: 'array', items: { $ref: '#/components/schemas/RustPlayerReach' } },
},
},
},
},
},
RustLinkRequest: {
type: 'object',
required: ['code'],
properties: {
code: {
type: 'string',
description: 'The six-character code /link handed the player in game. Good for five minutes, and it works once.',
example: 'K7M2PQ',
},
},
},
RustLinkResult: {
type: 'object',
description: 'The result of redeeming a code.',
properties: {
linked: { type: 'boolean', example: true },
link: { $ref: '#/components/schemas/RustLink' },
already: {
type: 'boolean',
description: 'True when this Steam id was already linked to the caller — a second press of the button, not an error.',
example: false,
},
},
},
RustAdminLinkList: {
type: 'object',
description: 'One users Rust identity, for the admin.users.detail panel (GET /admin/users/{id}/rust/links).',
properties: {
links: {
type: 'array',
items: {
type: 'object',
properties: {
steamId: { type: 'string', example: '76561198000000000' },
name: {
type: 'string',
nullable: true,
description: 'What the game last saw this player called, falling back to the name recorded at link time.',
example: 'Wanderer',
},
linkedName: { type: 'string', nullable: true, example: 'Wanderer' },
serverId: { type: 'string', nullable: true, example: 'main' },
linkedAt: { type: 'string', format: 'date-time' },
firstSeen: { type: 'string', format: 'date-time', nullable: true },
lastSeen: { type: 'string', format: 'date-time', nullable: true },
servers: {
type: 'array',
description: 'All-time totals per server, summed across every wipe.',
items: {
type: 'object',
properties: {
serverId: { type: 'string', example: 'main' },
serverName: { type: 'string', example: 'Main · Vanilla' },
kills: { type: 'integer', example: 41 },
deaths: { type: 'integer', example: 37 },
npcKills: { type: 'integer', example: 120 },
structures: { type: 'integer', example: 64 },
playtimeSec: { type: 'integer', example: 43200 },
wipes: { type: 'integer', example: 2 },
lastSeen: { type: 'string', format: 'date-time', nullable: true },
},
},
},
},
},
},
},
},
RustPermissionModel: {
type: 'object',
description:
'The whole permission model (GET /admin/rust/permissions): what the site authors, what each game reported back, and the names a grant may use.',
properties: {
groups: {
type: 'array',
description: 'Groups the site authors, mirrored into each in-scope game as a real group.',
items: {
type: 'object',
properties: {
name: { type: 'string', example: 'vip' },
title: { type: 'string', example: 'VIP' },
rank: { type: 'integer', example: 10 },
scope: {
type: 'string',
description: 'A server id, or `*` for every server.',
example: '*',
},
permissions: { type: 'array', items: { type: 'string', example: 'kits.vip' } },
members: {
type: 'array',
items: {
type: 'object',
properties: {
userId: { type: 'integer', example: 42 },
username: { type: 'string', example: 'wanderer' },
steamId: {
type: 'string',
nullable: true,
description: 'Null when this account has linked no Steam id, in which case the membership reaches nobody yet.',
example: '76561198000000000',
},
playerName: { type: 'string', nullable: true, example: 'Wanderer' },
},
},
},
},
},
},
grants: {
type: 'array',
description: 'Permissions held by one person without a group. Unlike membership, a direct grant reaches a player who has never connected.',
items: {
type: 'object',
properties: {
id: { type: 'integer', example: 7 },
userId: { type: 'integer', example: 42 },
username: { type: 'string', example: 'wanderer' },
permission: { type: 'string', example: 'kits.gold' },
scope: { type: 'string', example: 'main' },
source: {
type: 'string',
description: 'What authored it — `admin`, `adopted`, or a later phases own writer.',
example: 'admin',
},
note: { type: 'string', nullable: true, example: null },
grantedAt: { type: 'string', format: 'date-time' },
accounts: {
type: 'array',
description: 'The Steam accounts this grant reaches. Empty means it reaches nobody yet.',
items: {
type: 'object',
properties: {
steamId: { type: 'string', example: '76561198000000000' },
name: { type: 'string', nullable: true, example: 'Wanderer' },
},
},
},
},
},
},
servers: {
type: 'array',
description: 'The state of the mirror, per configured server.',
items: { $ref: '#/components/schemas/RustPermissionSyncState' },
},
drift: {
type: 'array',
description: 'What a game holds that the site did not author. Reported, never undone.',
items: {
type: 'object',
properties: {
id: { type: 'integer', example: 3 },
serverId: { type: 'string', example: 'main' },
kind: {
type: 'string',
description: 'One of `grant`, `member`, `group-permission`.',
example: 'grant',
},
subject: {
type: 'string',
description: 'A Steam id, or a group name.',
example: '76561198000000000',
},
object: {
type: 'string',
description: 'A permission name, or a group name.',
example: 'kits.admin',
},
username: {
type: 'string',
nullable: true,
description: 'The website account holding that Steam id, when there is one. Without it the drift cannot be adopted, only revoked.',
example: 'wanderer',
},
firstSeen: { type: 'string', format: 'date-time' },
},
},
},
catalogue: {
type: 'array',
items: { $ref: '#/components/schemas/RustPermissionCatalogueEntry' },
},
},
},
RustPermissionSyncState: {
type: 'object',
description: 'Whether one servers store matches what the site authors, and what its last report said.',
properties: {
serverId: { type: 'string', example: 'main' },
state: {
type: 'string',
description: 'One of `pending`, `ok`, `failed`.',
example: 'ok',
},
inSync: {
type: 'boolean',
description: 'True when the last successful push carried the set the site currently authors.',
example: true,
},
dirty: { type: 'boolean', example: false },
lastAttemptAt: { type: 'string', format: 'date-time', nullable: true },
lastOkAt: { type: 'string', format: 'date-time', nullable: true },
error: {
type: 'string',
nullable: true,
description: 'Why the last attempt failed — a transport word (`timeout`, `no-token`, `protocol-mismatch`) or the games own refusal.',
example: null,
},
report: {
type: 'object',
nullable: true,
description: 'The plugins report from the last successful sync.',
properties: {
applied: {
type: 'object',
properties: {
grants: { type: 'integer', example: 2 },
revokes: { type: 'integer', example: 0 },
groupsCreated: { type: 'integer', example: 1 },
members: { type: 'integer', example: 3 },
},
},
alreadyCorrect: { type: 'integer', example: 14 },
unresolved: {
type: 'array',
description: 'Permission names no loaded plugin on that server has registered. A grant naming one lands nowhere and is not recorded as pushed.',
items: { type: 'string', example: 'kits.gold' },
},
pending: {
type: 'array',
description: 'Memberships waiting on a first connection: the store has no user record to put in a group yet.',
items: { type: 'string', example: '76561198000000000:vip' },
},
},
},
},
},
RustPermissionCatalogue: {
type: 'object',
description: 'Every permission name the configured servers have registered (GET /admin/rust/permissions/catalogue).',
properties: {
permissions: {
type: 'array',
items: { $ref: '#/components/schemas/RustPermissionCatalogueEntry' },
},
},
},
RustPermissionCatalogueEntry: {
type: 'object',
description: 'One registered permission name, and which servers know it.',
properties: {
permission: { type: 'string', example: 'kits.vip' },
servers: { type: 'array', items: { type: 'string', example: 'main' } },
},
},
RustPermissionSyncResult: {
type: 'object',
description: 'What a forced sync produced (POST /admin/rust/permissions/sync).',
properties: {
servers: { type: 'array', items: { $ref: '#/components/schemas/RustPermissionSyncState' } },
drift: { type: 'array', items: { type: 'object' } },
},
},
RustUserPermissions: {
type: 'object',
description: 'One persons Rust privileges, for the admin.users.detail panel (GET /admin/users/{id}/rust/permissions).',
properties: {
groups: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string', example: 'vip' },
title: { type: 'string', example: 'VIP' },
scope: { type: 'string', example: '*' },
permissions: { type: 'array', items: { type: 'string', example: 'kits.vip' } },
},
},
},
grants: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'integer', example: 7 },
permission: { type: 'string', example: 'kits.gold' },
scope: { type: 'string', example: 'main' },
source: { type: 'string', example: 'admin' },
grantedAt: { type: 'string', format: 'date-time' },
},
},
},
reaches: {
type: 'array',
description: 'The Steam accounts these privileges reach. Empty means this person has linked nothing and holds them on paper only.',
items: { type: 'string', example: '76561198000000000' },
},
},
},
RustOnline: {
type: 'object',
description: 'Who is on one server (GET /public/rust/servers/{id}/online). Below the operators 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 servers 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 servers 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).',
properties: {
ok: { type: 'boolean', example: true },
status: {
type: 'string',
description: 'What happened, in one word — this is what tells a wrong URL from a wrong token from a mismatched protocol. One of `ok`, `no-token`, `unauthorized`, `protocol-mismatch`, `timeout`, `transport-error`, or `http-<code>`.',
example: 'ok',
},
sidecar: {
type: 'object',
nullable: true,
description: 'The sidecars own health document, or the mismatch detail on a protocol disagreement.',
properties: {
status: { type: 'string', example: 'ok' },
protocol: { type: 'integer', example: 1 },
plugin_connected: { type: 'boolean', example: true },
database: { type: 'string', example: 'ok' },
uptime: { type: 'string', example: '3h 2m' },
last_event: { type: 'string', format: 'date-time', nullable: true },
},
},
},
},
},
},
}