9 Commits

Author SHA1 Message Date
a1b6d155a1 Merge pull request 'fix(rust): answer refusals in the field core reads, and show the name the game last saw' (#7) from fix/phase-6-refusal-sentences into edge
Reviewed-on: #7
2026-09-21 22:39:43 +00:00
0876a1d568 fix(rust): answer refusals in the field core reads, and show the name the game last saw
All checks were successful
PR Checks / server-tests (pull_request) Successful in 15s
PR Checks / frozen-manifest (pull_request) Successful in 48s
PR Checks / client-build (pull_request) Successful in 7m49s
The two defects the phase 6 browser walk found and #6 described but did not
carry. They were written, walked and left uncommitted; `edge` still has the
shapes the walk condemned.

**Every refusal sentence was invisible.** Core's request primitive reads one
field — `(data && data.message) || res.statusText` — and this module has
answered `{ error: … }` since phase 1. It got away with it because every
failure until phase 6 landed in `ErrorState` on a page whose whole content was
missing, where a generic sentence is honest. A form is different: the sentence
IS the outcome, and the link page showed *Service Unavailable* for all four of
the refusals phase 6 exists to write. All 23 bodies now answer in `message` —
core's `Error` schema, which these routes' own `#swagger.responses` already
referenced, so the annotations stop being a claim the handlers contradict.

`test/errorShape.test.js` drives each outcome rather than grepping for the
field, and asserts the half that is easy to leave behind: a body carrying BOTH
fields renders correctly in a browser and keeps the wrong shape alive for the
next route that copies it.

**The player saw a stale name.** `/player/rust` showed the name recorded at
link time while the admin panel showed the one the game last saw — the same
person labelled two ways on one site, because a Rust name changes on a whim and
only the admin read joined `rust_players`. A LEFT JOIN, because an account can
be linked and never played on.

123 server tests, 39 client tests, `check:imports`, `check:bundle`,
`check:swagger`, `check:externals` — all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
2026-09-21 17:34:21 -05:00
f3e274b33d Merge pull request 'feat(rust): identity — a link code from the game, and the Steam id inside core's user page' (#6) from feat/phase-6-identity into edge
Reviewed-on: #6
2026-09-21 22:25:15 +00:00
0a1e558942 test(rust): grow the mount check for the slot it predicted
All checks were successful
PR Checks / server-tests (pull_request) Successful in 42s
PR Checks / client-build (pull_request) Successful in 44s
PR Checks / frozen-manifest (pull_request) Successful in -50s
`the manifest and the module's declared mounts agree` was written in phase 1 with
its own exception named in a comment: when `admin.users.detail` arrives, its
routes live on a resource core owns and the test must grow the exception
deliberately rather than let a route outside every declared mount arrive
unnoticed. This is that growth, and the test did its job — it failed on the first
run after the slot was filled.

A route is now legitimate if it is under a declared prefix OR under the mount of
a slot `module.json` declares, and a declared slot that contributes no route
fails too: core never checks that a declared slot was filled (`checkDeclared`
covers `mounts` alone), so this is the only place an exception widening the check
for nothing is noticed. Verified by pointing the slot mount at a path nothing
serves and watching it fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
2026-09-21 08:19:41 -05:00
baffaa46c9 feat(rust): identity — a link code from the game, and the Steam id inside core's user page
R1's identity link, site-side, and R13's first extension slot. A player types
/link in game, the plugin hands them a six-character code privately, and they
enter it here; the site records who owns which Steam account, and an operator
sees that on core's own `/admin/users/:id` page.

**The site is the author of record and the game holds nothing.** There is no
per-account store in Rust that survives a wipe, and phase 7 needs the site
authoritative anyway — it pushes permissions INTO the game keyed by Steam id. A
copy in the game would be a second thing to reconcile every wipe, for no question
it could answer better.

## D24 — a code is minted by ONE server, so every server is asked

Nothing in six characters says where it came from. The fleet is asked in turn and
the first `link.ok` wins; the others answer `unknown` and nothing happens there,
because a code is only spent at the server that actually holds it. Asking the
player to pick was rejected: a wrong pick would come back indistinguishable from
a wrong code, and that is the one refusal which must not be ambiguous.

**"Every reachable server refused" is not the same answer as "a server was
unreachable."** Collapsing them tells a player whose server is down that their
code is wrong — so they run /link again on that same server and are told the same
thing for as long as it stays down. `unsure` is that case, and it says to try
again rather than to fetch a new code.

## D23 — a Steam id another account holds is refused, never moved

The primary key is `steam_id`, and it is load-bearing rather than tidy: phase 7
grants permissions against a link and phase 13 hangs entitlements off it, so a
silent move is an account takeover performed by typing six characters. The
refusal names the holder, because the advice is unusable without it. The INSERT
is a plain INSERT for the same reason — `ON DUPLICATE KEY UPDATE` here would BE
that move — and the duplicate-key error is the refusal for the race the check
above cannot close.

The way out is `/unlink` in game, which reaches the site off the ingest feed
rather than through a route (the plugin has no link to delete). D25 adds the
other way out: staff can sever a link from the admin panel, for a player who
cannot reach that Steam account in game.

## The slot, and the hole it found in this repo's own generator

`admin.users.detail` is declared in `module.json` AND registered in `index.js`
AND filled by the chunk — three places, because the server half and the client
half are different registrations that share one name.

`swaggerFragment.js` knew only about tier routers, so the two routes under
`/admin/users/:id` were generated by nothing: a fragment that was internally
consistent and described two routes fewer than the module serves. A slot's mount
is core's and cannot be derived here, so it is a fourth constant beside
`TIER_BASE` — held to account by the frozen-manifest job, which was verified to
catch exactly this by removing the two paths and watching it fail.

## Smaller things worth knowing

- **Core's `useAsync` has no `refresh`.** A counter in the deps is how a page
  re-reads after its own write; it blanks while it re-reads, which is right here
  and is exactly what made it wrong for a poll.
- **Every player-portal nav row needs an `icon`** — core draws one on every row,
  and the client suite says so. This module had no icons file until now, because
  the public header is text buttons.
- The two new frame kinds are STAFF-only. Neither carries a code, but both name a
  Steam id beside a website account's activity, and that join is not a public
  fact about what happened on a server.
- The link code route carries its own rate limiter rather than core's
  `accountChangeLimiter`: this is guessing somebody else's secret, not changing
  your own password, and a shared counter would let one policy set the other.

Protocol 3 on all three declaration sites; 17 new tests, 136 green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
2026-09-21 08:18:48 -05:00
28e46771b1 Merge pull request 'feat: declare rust as the module's identity capability (phase 5, D16)' (#5) from feat/phase-5-capability into edge
Reviewed-on: #5
2026-09-17 09:22:12 +00:00
8df850f73e feat: declare rust as the module's identity capability
All checks were successful
PR Checks / server-tests (pull_request) Successful in 14s
PR Checks / client-build (pull_request) Successful in 14s
PR Checks / frozen-manifest (pull_request) Successful in -1m4s
Phase 5 is the Android app's leg of this module's read path (R10), and it
gates its Rust navigation on one capability string the way `module-uo`'s five
shard rows gate on `shard`. There was no such string here: the five this module
declared all name a SURFACE, and core flattens every started module's
capabilities into one list, so `servers` is a word another module could declare
tomorrow and silently reveal these screens on a site that does not run Rust.

`rust` is the string only this module can mean. It is asserted against
`module.json`'s own `id` rather than a literal, so the two cannot drift.

The README says why it is not redundant with `id`: `id` is a mount prefix, and
MODULE_API.md §2.9 forbids a client inferring a route from a capability. Gating
on `id` would quietly make those the same thing.

Decided by the org lead as D16, 2026-09-16.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-16 21:55:17 -05:00
7e1f037aad Merge pull request 'feat: the first pages, and what a browser walk found behind them' (#4) from feat/phase-4-first-pages into main
All checks were successful
Release / release (push) Successful in 20s
Reviewed-on: #4
2026-09-17 02:43:27 +00:00
22fd8c5da7 feat: the first pages, and what a browser walk found behind them
All checks were successful
PR Checks / client-build (pull_request) Successful in 15s
PR Checks / frozen-manifest (pull_request) Successful in 36s
PR Checks / server-tests (pull_request) Successful in 7m58s
Phase 4. `/rust` is the server list and the module's landing page (D12);
`/rust/servers/:id` is one server with four tabs — feed, leaderboard, who is
on, wipes (D13). Everything selectable lives in the URL, so any view of the
page is a link. The feed and the presence list poll every twenty seconds while
the tab is visible and not at all when it is not (D14); the leaderboard and the
wipe list load once. `site.footer.status` is filled with a live server and
player count (D15).

Nothing on these pages calls a game server. Every field comes from this
module's own tables, which is what the phase criterion is about: the site
renders the last thing each server said while every server is off.

Walking that criterion in a browser against a live rig found four defects, two
of them already shipped in phase 3:

  * An unreachable refresh called `putState` — the whole-row write — with two
    fields, so a host that rebooted lost its hostname, map, size, seed and wipe
    id. The list then read "Offline" with nothing beside it, which is not "here
    is what we know" but "we have never heard of it". `markUnreachable` now
    moves three columns and mentions no others.
  * "Last reported" read `updated_at`, which a FAILED poll writes too — so an
    offline server claimed it had reported just now, every thirty seconds, for
    as long as it stayed down. `last_seen_at` is the new column, moved only by a
    frame that arrived.
  * Feed rows showed a bare time of day, so three events from six weeks ago all
    read as this afternoon once the feed was filtered to a past wipe.
  * `/rust/servers/typo` rendered core's ErrorState under its own heading and
    read "No such server / Something went wrong", sending a reader who mistyped
    a URL looking for an outage.

Also: a detail route (`GET …/servers/:id`), because it is the only route under
that path that can say a server does not exist — the other four answer an empty
list for an id nobody configured, and each of those is a good answer to its own
question.

`useAsync` cannot poll: it blanks its data on every dependency change, so a
twenty-second refresh built on it would clear the killfeed and re-fill it four
times a minute. `hooks/usePolled.js` is the module's own, invisible when it
succeeds and keeping the rows when it fails.

The client test fake was *nearly* core — it prefixed routes without stripping
the trailing separator, so the first module to register an index route failed
the nav check for a link that works in a browser. It now copies core's line
character for character.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-16 21:40:28 -05:00
53 changed files with 4969 additions and 122 deletions

View File

@@ -36,17 +36,51 @@ rows here; the website core never learns there is more than one.
| Surface | Route |
|---|---|
| Public | `GET /api/v1/public/rust/servers` — every server and what it last reported |
| Player | `GET /api/v1/player/rust/servers`the same, on the authenticated tier |
| Public | `GET /servers/:id`one server, or a `404`; the only route under `:id` that can say a server does not exist |
| Public | `GET …/servers/:id/events` — the feed, served from a default-deny allowlist (`server/catalogue.js`) |
| Public | `GET …/servers/:id/leaderboard` — per wipe, or all-time as those rows summed |
| Public | `GET …/servers/:id/wipes` and `…/online` |
| Player | `GET /api/v1/player/rust/servers` — the server list, on the authenticated tier |
| Admin | `GET/PUT/DELETE /api/v1/admin/rust/servers` and `POST …/:id/test` |
| Page | `/rust/servers` |
| Pages | `/rust` — the server list, and the module's landing page |
| Pages | `/rust/servers/:id` — one server: feed, leaderboard, who is on, wipes |
| Slot | `site.footer.status` — a live server/player count in core's footer |
Two tables, `rust_servers` (configuration) and `rust_server_state` (what each sidecar reported).
Every page reads this module's own tables and never calls a game server, which is what lets the
whole surface render while every server in the fleet is off. Tab, feed filter, wipe and leaderboard
sort all live in the URL, so any view of it is a link.
Seven tables: `rust_servers` (configuration), `rust_server_state` and `rust_presence` (observed
state), `rust_wipes`, `rust_players`, `rust_player_wipe_stats` and `rust_gather_totals` (the record a
wipe does not erase), plus the bounded `rust_events` window and the `rust_ingest_cursor`.
The rest of the module — identity, site-owned permissions, Teams from Rust's clans, notifications,
events, the live map, Discord commands — arrives phase by phase. **Nothing is registered before it
has something behind it:** a declared trigger nothing emits and a declared slot nothing fills are
both surfaces an operator can configure and then wait on, which is worse than an absent one.
### What a client feature-detects on
`module.json` declares six capability strings, and `GET /api/v1/public/modules` hands them to any
client that asks — the website's own nav, and the Android app (`docs/modules/rust/PLAN.md` R10).
Five of them name a surface: `servers`, `killfeed`, `leaderboard`, `presence`, `wipes`.
The sixth is `rust`, and it names **the module itself**. It looks redundant beside `id`, and it is
not, for two reasons worth writing down before somebody tidies it away:
- **A client that asks "is this module installed" has nowhere else to ask.** Core flattens every
started module's capabilities into one list, so `servers` alone is a word another module could
declare tomorrow and silently reveal this one's screens. `rust` is the string that can only mean
this module, and it is the single gate a whole navigation group hangs on — exactly the job `shard`
does for `module-uo`.
- **`id` answers a different question.** It is a *mount prefix* (§2.1 requires it to equal the
directory core loads the module from), and `MODULE_API.md` §2.9 is explicit that a client must
never infer a route from a capability. Gating on `id` would quietly make the two the same thing,
and the day a client builds `/<id>/servers` from it, the contract that lets this module move its
own pages is gone.
An unknown capability is absent, and no route is ever derived from one.
## Build and check
```bash

View File

@@ -25,6 +25,45 @@ const { request: req, BASE } = rg.api
// registers under the `/rust` prefix `module.json` declares.
export const servers = {
list: () => req('/public/rust/servers'),
// One server, and the only route under `/servers/:id` that can answer "no such
// server": the four below answer an empty list for an id nobody ever
// configured, because an unknown server genuinely has no events.
get: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}`),
// `kind` is a comma-separated list and `wipe` a wipe id; both are optional and
// both are built here rather than in a page, so the query string this module
// sends exists in one file.
events: (id, { kinds = null, wipe = null, limit = null } = {}) =>
req(`/public/rust/servers/${encodeURIComponent(id)}/events${query({
kind: kinds && kinds.length ? kinds.join(',') : null,
wipe,
limit,
})}`),
leaderboard: (id, { wipe = null, sort = null, limit = null } = {}) =>
req(`/public/rust/servers/${encodeURIComponent(id)}/leaderboard${query({ wipe, sort, limit })}`),
wipes: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/wipes`),
online: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/online`),
}
/**
* A query string from the parameters that have a value, or `''`.
*
* **An absent parameter must be absent, not empty.** `?wipe=` is not the same
* question as no `wipe` at all — the first asks for a wipe whose id is the empty
* string — and a page that sends one because a `<select>` is on "All time" gets
* an empty leaderboard and no error.
*/
function query(params) {
const search = new URLSearchParams()
for (const [key, value] of Object.entries(params)) {
if (value !== null && value !== undefined && value !== '') search.set(key, String(value))
}
const string = search.toString()
return string ? `?${string}` : ''
}
// ── player ────────────────────────────────────────────────────────────────
@@ -35,6 +74,20 @@ export const playerServers = {
list: () => req('/player/rust/servers'),
}
// R1's identity link, from the signed-in player's side.
//
// **The code is the whole of what goes up.** The site has no idea which server
// minted it — nothing in six characters says — so the server half asks each
// configured server in turn (D24). A page that asked the player to pick would be
// asking them a question the site can answer itself, and a wrong pick would come
// back indistinguishable from a wrong code.
export const playerLinks = {
list: () => req('/player/rust/links'),
confirm: (code) => req('/player/rust/link', { method: 'POST', body: { code } }),
remove: (steamId) =>
req(`/player/rust/links/${encodeURIComponent(steamId)}`, { method: 'DELETE' }),
}
// ── admin ─────────────────────────────────────────────────────────────────
// **`sidecarToken` goes up and never comes back.** The list answers `hasToken`,
// and a save that omits the field leaves the stored credential alone — so an
@@ -50,8 +103,23 @@ export const admin = {
req(`/admin/rust/servers/${encodeURIComponent(id)}/test`, { method: 'POST' }),
}
// ── the admin.users.detail extension slot ─────────────────────────────────
//
// The client half of R13's first slot. Core hands the component a `userId` and
// NOTHING else — not a client — so an extension builds its own bindings for the
// routes it registered at the other end (§3.5). These two are the only calls in
// this file whose path is core's rather than this module's: the resource is
// core's user, and the module's own segment is the part after it.
export const adminUserLinks = {
list: (userId) => req(`/admin/users/${encodeURIComponent(userId)}/rust/links`),
remove: (userId, steamId) =>
req(`/admin/users/${encodeURIComponent(userId)}/rust/links/${encodeURIComponent(steamId)}`, {
method: 'DELETE',
}),
}
// Exported for the rare caller that needs the base itself — an `<img src>`, a
// download link, an EventSource. Reach for `request` first.
export { BASE }
export { BASE, query }
export default { servers, playerServers, admin, BASE }
export default { servers, playerServers, playerLinks, admin, adminUserLinks, BASE }

View File

@@ -0,0 +1,141 @@
// ── The feed: what happened on one server ─────────────────────────────────
//
// Rows come from `/public/rust/servers/:id/events`, which serves a default-deny
// ALLOWLIST (`server/catalogue.js`). Everything carrying an IP address, a
// player's report about another player, or the grid square somebody's base is in
// is stored and never answered here — so this component cannot leak one by
// forgetting to filter, which is the point of the boundary living on the server.
//
// It polls (org lead, phase 4): every twenty seconds while the tab is visible,
// paused when it is not. `usePolled` keeps the rows on screen across a refresh —
// see the comment at the top of that file for why core's `useAsync` cannot do
// this job.
import { EmptyState, ErrorState, Loading } from '../core.js'
import { describe, FILTERS, kindsFor } from '../lib/feed.js'
import { ago, clock } from '../lib/format.js'
import usePolled from '../hooks/usePolled.js'
import api from '../api.js'
const TONE = {
kill: 'var(--accent-bright)',
death: 'var(--muted)',
join: 'var(--mode-live, #5fb98a)',
leave: 'var(--dim)',
chat: 'var(--text)',
server: 'var(--mode-maint, #e6c26a)',
other: 'var(--muted)',
}
export default function Feed({ serverId, wipeId, filter, onFilter }) {
const kinds = kindsFor(filter)
const { data, error, loading, at } = usePolled(
() => api.servers.events(serverId, { kinds, wipe: wipeId, limit: 100 }),
// The key is the QUESTION. Changing server, wipe or filter blanks the rows,
// because what is on screen is an answer to a different one; a poll tick
// does not, because it is the same question asked again.
{ key: `${serverId}|${wipeId || ''}|${filter}`, intervalMs: 20_000 },
)
const events = data ? data.events : []
return (
<div>
<div
className="sans"
style={{ display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'center', marginBottom: 16 }}
>
<label style={{ color: 'var(--dim)', fontSize: '0.78rem' }}>
Showing{' '}
<select
value={filter}
onChange={(e) => onFilter(e.target.value)}
style={selectStyle}
>
{FILTERS.map((f) => (
<option key={f.id} value={f.id}>{f.label}</option>
))}
</select>
</label>
{/* What a refresh is FOR: saying when the page last managed one. Without
it a feed that stopped updating looks exactly like a quiet server. */}
{at && (
<span style={{ color: 'var(--dim)', fontSize: '0.74rem' }}>updated {ago(at)}</span>
)}
{error && (
<span style={{ color: 'var(--mode-maint, #e6c26a)', fontSize: '0.74rem' }}>
the last refresh failed showing what we had
</span>
)}
</div>
{loading && <Loading />}
{/* An error with nothing to fall back on is the only case that takes over
the panel. A failed REFRESH keeps the rows and says so in the line
above, because a site whose premise is "it renders while the game is
off" must not blank itself the first time a request does. */}
{error && !data && <ErrorState error={error} />}
{data && events.length === 0 && (
<EmptyState
title="Nothing here yet"
message="Nothing this server has reported matches. A server that has just been added has no history until it says something."
/>
)}
{events.length > 0 && (
<ol style={{ listStyle: 'none', margin: 0, padding: 0 }}>
{events.map((event) => {
const line = describe(event)
return (
<li
key={event.id}
style={{
display: 'flex',
gap: 12,
alignItems: 'baseline',
padding: '7px 0',
borderBottom: '1px solid var(--line-soft, var(--line))',
}}
>
<time
className="sans"
dateTime={new Date(event.t).toISOString()}
title={new Date(event.t).toLocaleString()}
style={{ flex: 'none', color: 'var(--dim)', fontSize: '0.74rem', minWidth: '5.6rem' }}
>
{clock(event.t)}
</time>
<span style={{ color: TONE[line.tone] || 'var(--muted)', fontSize: '0.92rem' }}>
{line.actor && <strong style={{ color: 'var(--ink)' }}>{line.actor}</strong>}
{line.actor && (line.join || ' ')}
{line.verb}
{line.subject && ' '}
{line.subject && <strong style={{ color: 'var(--ink)' }}>{line.subject}</strong>}
{line.detail && (
<span className="sans" style={{ color: 'var(--dim)', fontSize: '0.76rem' }}>
{' · '}
{line.detail}
</span>
)}
</span>
</li>
)
})}
</ol>
)}
</div>
)
}
const selectStyle = {
background: 'var(--panel-flat, transparent)',
color: 'var(--text)',
border: '1px solid var(--line)',
borderRadius: 'var(--radius-input, 6px)',
padding: '3px 8px',
fontSize: '0.78rem',
}

View File

@@ -0,0 +1,73 @@
// ── This module's fill for core's `site.footer.status` slot ───────────────
//
// R13, and the contract is MODULE_API.md §3.7. Core owns the position in the
// footer's info row and the separator around it, and passes `linkStyle` so the
// row stays visually one row. **The label, the destination, the data and whether
// anything renders at all are this component's** — that is the whole division,
// and it is why the slot is named for a place rather than for a meaning.
//
// ── The live count, and what it costs ─────────────────────────────────────
//
// The org lead chose a live count ("3 servers · 42 online") over a static link,
// so this fetches. Be clear-eyed about where it fetches from: core renders
// `SiteFooter` inside `PublicLayout`, and every public page renders
// `PublicLayout` ITSELF (§3.3) — so this component mounts once per public page
// view, not once per session. Every public page on the site therefore carries one
// `/public/rust/servers` request, including pages that have nothing to do with
// Rust.
//
// Two things keep that honest rather than merely cheap:
//
// • **It renders NOTHING until it has an answer, and nothing again if the
// request fails.** An unfilled slot renders nothing and core's `wrap` takes
// the separator with it, so a failed fetch degrades to exactly the footer an
// instance with no module installed has. A spinner in a footer would be worse
// than silence on every page of the site.
// • **It never polls.** One request per page view is a cost; a timer in the
// footer of every page would be a different kind of thing entirely.
//
// If that per-page request ever shows up in an operator's logs as a problem, the
// fix is a short-lived module-scope cache here — the decision to keep the number
// live stays intact, and nothing else on the site has to change.
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../api.js'
export default function FooterStatus({ linkStyle }) {
const [summary, setSummary] = useState(null)
useEffect(() => {
let live = true
api.servers
.list()
.then(({ servers }) => {
if (!live) return
// `online` already accounts for staleness — the model refuses to let a
// row that has not been written in five minutes claim a server is up —
// so this is a sum, not a judgement.
setSummary({
servers: servers.length,
players: servers.reduce((total, server) => total + (server.online ? server.players : 0), 0),
})
})
// Silence, deliberately. This is the footer of every page on the site; a
// module that cannot reach its own API has nothing to say there.
.catch(() => {})
return () => {
live = false
}
}, [])
if (!summary || summary.servers === 0) return null
return (
<Link to="/rust" style={linkStyle}>
{summary.servers === 1 ? '1 server' : `${summary.servers} servers`}
{' · '}
{summary.players === 1 ? '1 online' : `${summary.players} online`}
</Link>
)
}

View File

@@ -0,0 +1,110 @@
// ── The leaderboard ───────────────────────────────────────────────────────
//
// Per wipe when a wipe is selected, all-time when it is not (R12). The two are
// the same rows summed differently rather than two sets of counters, so they can
// never disagree — which is worth knowing here because it means "All time" is
// not a slower or less accurate answer, it is the same table without a WHERE.
//
// It does NOT poll. A leaderboard moves on the scale of a session; a table that
// re-sorted itself under the reader's cursor every twenty seconds would be worse
// than one that is four minutes old, and the page has a `Refresh` on the tab
// strip for anybody who disagrees.
import { EmptyState, ErrorState, Loading, useAsync } from '../core.js'
import { ago, count, duration, shortId } from '../lib/format.js'
import api from '../api.js'
// `sort` is the API's own vocabulary (`kills`, `deaths`, `npcKills`, `playtime`),
// and the column it maps to is this file's. Keeping them in one list is what
// stops a header that sorts by something other than what it says.
const COLUMNS = [
{ key: 'kills', label: 'Kills', sort: 'kills', value: (r) => count(r.kills) },
{ key: 'deaths', label: 'Deaths', sort: 'deaths', value: (r) => count(r.deaths) },
{ key: 'npcKills', label: 'NPC kills', sort: 'npcKills', value: (r) => count(r.npcKills) },
{ key: 'structures', label: 'Structures', sort: null, value: (r) => count(r.structures) },
{ key: 'playtimeSec', label: 'Played', sort: 'playtime', value: (r) => duration(r.playtimeSec) },
]
export default function Leaderboard({ serverId, wipeId, sort, onSort }) {
const { data, loading, error } = useAsync(
() => api.servers.leaderboard(serverId, { wipe: wipeId, sort, limit: 50 }),
[serverId, wipeId, sort],
)
const rows = data ? data.leaderboard : []
if (loading) return <Loading />
if (error) return <ErrorState error={error} />
if (rows.length === 0) {
return (
<EmptyState
title="No scores yet"
message={
wipeId
? 'Nobody has done anything countable on this wipe yet.'
: 'This server has not reported anything countable yet.'
}
/>
)
}
return (
<div style={{ overflowX: 'auto' }}>
<table className="sans" style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.86rem' }}>
<thead>
<tr style={{ textAlign: 'left', color: 'var(--dim)', fontSize: '0.72rem', letterSpacing: '0.08em' }}>
<th style={{ ...cell, textTransform: 'uppercase' }}>Player</th>
{COLUMNS.map((column) => (
<th key={column.key} style={{ ...cell, textAlign: 'right', textTransform: 'uppercase' }}>
{column.sort ? (
<button
type="button"
onClick={() => onSort(column.sort)}
aria-label={`Sort by ${column.label}`}
style={{
cursor: 'pointer',
background: 'none',
border: 'none',
padding: 0,
font: 'inherit',
letterSpacing: 'inherit',
textTransform: 'inherit',
color: column.sort === sort ? 'var(--accent-bright)' : 'var(--dim)',
}}
>
{column.label}
</button>
) : (
column.label
)}
</th>
))}
<th style={{ ...cell, textAlign: 'right', textTransform: 'uppercase' }}>Last seen</th>
</tr>
</thead>
<tbody>
{rows.map((row, index) => (
<tr key={row.steamId} style={{ borderTop: '1px solid var(--line-soft, var(--line))' }}>
<td style={cell}>
<span style={{ color: 'var(--dim)', marginRight: 8 }}>{index + 1}</span>
{/* A player this module has never seen NAMED is shown by the tail
of their id rather than as a blank: the row is real, and a
nameless one reads as a rendering fault. */}
<strong style={{ color: 'var(--ink)' }}>{row.name || shortId(row.steamId)}</strong>
</td>
{COLUMNS.map((column) => (
<td key={column.key} style={{ ...cell, textAlign: 'right' }}>
{column.value(row)}
</td>
))}
<td style={{ ...cell, textAlign: 'right', color: 'var(--dim)' }}>{ago(row.lastSeen)}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
const cell = { padding: '8px 10px', whiteSpace: 'nowrap' }

View File

@@ -0,0 +1,94 @@
// ── Who is on the server right now ────────────────────────────────────────
//
// Read from the presence BOARD, not counted from connect and disconnect events:
// the bridge re-sends the whole board on every connect and every sixty seconds,
// so this is right even after the website has missed something (PROTOCOL.md
// §8.3). Counting transitions instead would drift, and drift in the direction
// people notice — players who never left.
//
// It polls with the feed, because "who is on" is the one thing on this page that
// is a live question.
import { EmptyState, ErrorState, Loading } from '../core.js'
import { duration, shortId } from '../lib/format.js'
import usePolled from '../hooks/usePolled.js'
import api from '../api.js'
export default function Online({ serverId, online }) {
const { data, error, loading } = usePolled(() => api.servers.online(serverId), {
key: serverId,
intervalMs: 20_000,
})
const players = data ? data.players : []
if (loading) return <Loading />
if (error && !data) return <ErrorState error={error} />
if (players.length === 0) {
return (
<EmptyState
title={online ? 'Nobody is on' : 'The server is offline'}
message={
online
? 'The server is up and the island is empty. Somebody has to be first.'
: 'Presence is the one thing on this page that cannot be answered from the record — it is who is connected now, and nothing is.'
}
/>
)
}
return (
<>
{/* A board is the last one that ARRIVED, and an unreachable sidecar does not
clear it — deliberately, because the rows are still the best answer
anybody has. But presented bare they read as "these people are on right
now", which is the one thing an offline server cannot be saying. The
page walk found this with a fixture server whose header said Offline
above three apparently-connected players. */}
{!online && (
<p className="sans" style={{ color: 'var(--dim)', fontSize: '0.8rem', marginTop: 0 }}>
This server is offline. Below is the last board it sent, not who is on it now.
</p>
)}
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
{players.map((player) => (
<li
key={player.steamId}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'baseline',
gap: 12,
padding: '8px 0',
borderBottom: '1px solid var(--line-soft, var(--line))',
}}
>
<span>
<strong style={{ color: 'var(--ink)' }}>{player.name || shortId(player.steamId)}</strong>
{/* Sleeping is not idle and not offline — a sleeping player's body is
in the world and can be killed, which is why the board carries the
flag at all. */}
{player.sleeping && (
<span className="sans" style={{ color: 'var(--dim)', fontSize: '0.76rem' }}> · sleeping</span>
)}
</span>
{/* `connectedAt` is absent for a player who was already on when the
plugin loaded — an unknown session length, which is not a session of
no length. Saying nothing is the honest render of that. */}
<span className="sans" style={{ color: 'var(--dim)', fontSize: '0.78rem', whiteSpace: 'nowrap' }}>
{player.connectedAt ? `on for ${sessionSoFar(player.connectedAt)}` : ''}
</span>
</li>
))}
</ul>
</>
)
}
/** How long a player has been on, from the DATETIME the board reported. */
function sessionSoFar(connectedAt) {
const since = Date.parse(connectedAt)
if (Number.isNaN(since)) return ''
return duration((Date.now() - since) / 1000)
}

View File

@@ -0,0 +1,63 @@
// ── Tabs, bundled rather than borrowed ────────────────────────────────────
//
// The shared kit is nine members and it is CLOSED (MODULE_API.md §3.4): layout,
// headings, the three data-page states, the fetch hook, the session, the site
// and `Slot`. A tab strip is not in it, so it is here — which is the kit working
// as designed rather than a gap in it. What the kit guarantees is that a module
// page looks like the site while it loads and while it fails; everything a page
// builds on top of that is the module's own.
//
// It is styled with core's CSS VARIABLES and its `.pill` class rather than with
// colours of its own, so it re-themes with the instance (THEMING_AND_NAV.md).
// The one class this module must never write by hand is the shell wrapper —
// `PublicLayout`'s `shell` prop exists precisely so that one stays core's.
//
// **The selected tab lives in the URL, not in this component.** A tab strip that
// owned its own state would make every panel on this page unlinkable: "look at
// the leaderboard for this server" would be a sentence rather than a link, back
// would leave the page entirely, and a refresh would land on the first tab. So
// this is a controlled component and `ServerDetail` keeps the state in a search
// parameter.
export default function Tabs({ tabs, active, onSelect, label = 'Sections' }) {
return (
<div
role="tablist"
aria-label={label}
className="sans"
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 8,
borderBottom: '1px solid var(--line)',
paddingBottom: 12,
marginBottom: 20,
}}
>
{tabs.map((tab) => {
const selected = tab.id === active
return (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={selected}
onClick={() => onSelect(tab.id)}
style={{
cursor: 'pointer',
padding: '6px 14px',
borderRadius: 'var(--radius-pill, 999px)',
fontSize: '0.82rem',
letterSpacing: '0.04em',
border: `1px solid ${selected ? 'var(--accent)' : 'var(--line)'}`,
background: selected ? 'var(--blue)' : 'transparent',
color: selected ? 'var(--accent-bright)' : 'var(--muted)',
}}
>
{tab.label}
</button>
)
})}
</div>
)
}

View File

@@ -0,0 +1,54 @@
// ── "This wipe" or "All time" ─────────────────────────────────────────────
//
// One control, used by two panels, because the wipe is a property of the PAGE
// rather than of the feed or the leaderboard — a reader who has chosen last
// month's map means it for both, and two selects that could disagree is a page
// that shows one wipe's kills next to another's leaderboard.
//
// It loads the wipe list itself. That is a second request for the same list the
// Wipes tab fetches, and it is the right trade: the alternative is the page
// fetching it on mount for a control most visitors never touch, on every visit,
// for every server.
import { useAsync } from '../core.js'
import { day } from '../lib/format.js'
import api from '../api.js'
/** The value that means "no wipe filter at all". Never the empty string — see `api.js`'s `query`. */
export const ALL_TIME = 'all'
export default function WipeSelect({ serverId, value, onChange, currentWipeId }) {
const { data } = useAsync(() => api.servers.wipes(serverId), [serverId])
const wipes = data ? data.wipes : []
// A server with one wipe has nothing to choose between, so the control is not
// offered. "All time" and "this wipe" are the same answer there, and a select
// with one real option is furniture that invites a question with no answer.
if (wipes.length < 2) return null
return (
<label className="sans" style={{ color: 'var(--dim)', fontSize: '0.78rem' }}>
Wipe{' '}
<select
value={value || ALL_TIME}
onChange={(event) => onChange(event.target.value)}
style={{
background: 'var(--panel-flat, transparent)',
color: 'var(--text)',
border: '1px solid var(--line)',
borderRadius: 'var(--radius-input, 6px)',
padding: '3px 8px',
fontSize: '0.78rem',
}}
>
<option value={ALL_TIME}>All time</option>
{wipes.map((wipe) => (
<option key={wipe.wipeId} value={wipe.wipeId}>
{day(wipe.saveCreatedAt || wipe.firstSeen)}
{wipe.wipeId === currentWipeId ? ' (current)' : ''}
</option>
))}
</select>
</label>
)
}

View File

@@ -0,0 +1,85 @@
// ── Every wipe this server has had ────────────────────────────────────────
//
// The list is what makes the rest of the page navigable — picking a wipe here
// filters the feed and the leaderboard — and it is also the proof R12 asks for:
// a wipe that ended is still here, with its record still attached. A Rust server
// wipes monthly, and a community site that forgot the previous map every time
// would throw away most of what it knows about its own players.
//
// `wipeId` is derived by the bridge PLUGIN from the save's creation time and
// stamped on every frame (PROTOCOL.md §8.2), so the id in this list is the same
// id the events and the leaderboard filter by. There is no second derivation
// anywhere that could disagree.
import { EmptyState, ErrorState, Loading, useAsync } from '../core.js'
import { ago, day } from '../lib/format.js'
import api from '../api.js'
export default function Wipes({ serverId, currentWipeId, selected, onSelect }) {
const { data, loading, error } = useAsync(() => api.servers.wipes(serverId), [serverId])
const wipes = data ? data.wipes : []
if (loading) return <Loading />
if (error) return <ErrorState error={error} />
if (wipes.length === 0) {
return (
<EmptyState
title="No wipes recorded"
message="A wipe appears here once this server has reported something during it."
/>
)
}
return (
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
{wipes.map((wipe) => {
const current = wipe.wipeId === currentWipeId
const active = wipe.wipeId === selected
return (
<li key={wipe.wipeId} style={{ borderBottom: '1px solid var(--line-soft, var(--line))' }}>
<button
type="button"
onClick={() => onSelect(wipe.wipeId)}
style={{
display: 'flex',
width: '100%',
gap: 12,
alignItems: 'baseline',
justifyContent: 'space-between',
padding: '10px 6px',
cursor: 'pointer',
background: active ? 'var(--blue)' : 'transparent',
border: 'none',
color: 'inherit',
font: 'inherit',
textAlign: 'left',
}}
>
<span>
<strong style={{ color: 'var(--ink)' }}>
{/* The save's creation time is the wipe's own date; `firstSeen`
is when THIS website first heard about it, and they differ
by however long the module was not installed. The first is
the wipe, so it leads. */}
{day(wipe.saveCreatedAt || wipe.firstSeen)}
</strong>
{current && (
<span className="sans" style={{ color: 'var(--mode-live, #5fb98a)', fontSize: '0.74rem' }}>
{' · current'}
</span>
)}
<span className="sans" style={{ display: 'block', color: 'var(--dim)', fontSize: '0.74rem' }}>
{wipe.wipeId}
</span>
</span>
<span className="sans" style={{ color: 'var(--dim)', fontSize: '0.78rem', whiteSpace: 'nowrap' }}>
last heard {ago(wipe.lastSeen)}
</span>
</button>
</li>
)
})}
</ul>
)
}

View File

@@ -19,6 +19,11 @@
import { registry, coreApiVersion } from './core.js'
import Servers from './routes/public/Servers.jsx'
import ServerDetail from './routes/public/ServerDetail.jsx'
import Account from './routes/player/Account.jsx'
import UserRustSections from './routes/admin/UserRustSections.jsx'
import FooterStatus from './components/FooterStatus.jsx'
import { IconLink } from './icons.jsx'
// The module id, exactly as `module.json` spells it. Core keys the registry by it
// and prefixes every route path with it.
@@ -33,7 +38,7 @@ const ID = 'rust'
// installed side by side cannot collide, and an operator can see from a URL which
// module served it.
//
// So this page is at `/rust/servers`.
// So the list below is at `/rust` and the detail page at `/rust/servers/:id`.
//
// **Note what is NOT here: an auth wrapper.** `gate: { roles: [...] }` is
// available and core applies it as its own `RoleGate`; supplying your own is not
@@ -41,11 +46,29 @@ const ID = 'rust'
// see what, and they only do if one thing decides.
//
// R8's landing page is the server list, and `/rust/servers/:id` hangs beneath it.
// The detail route is a later phase's, and it is deliberately not stubbed here: a
// registered route that renders nothing is a 200 with a blank page, which is
// worse than the 404 an unregistered one gives.
//
// **The list is registered with an EMPTY path**, which core renders as the
// module's namespace root: `/rust`. The prefixing code strips the separator it
// would otherwise leave behind (`registry.js`: `${id}/${path}` with trailing
// slashes trimmed), so a module can own its own root without being able to spell
// its way out of it. Phase 1 served this page at `/rust/servers` and left `/rust`
// to core's CMS catch-all; the org lead settled it at `/rust` in phase 4, so the
// address an operator links to is the module's name.
//
// React Router ranks a static segment above a dynamic one, so `/rust` wins
// against core's `/:slug` CMS route without depending on registration order.
//
// The player route is registered with an empty path for the same reason the
// public list is: `/player/rust` is the whole of what this module asks a player
// to do, and a landing page above one page is a page nobody wants. Core applies
// its own portal chrome and its own auth gate to the tier, so the component
// renders no layout and re-implements no check.
registry.registerRoutes(ID, {
public: [{ path: 'servers', element: <Servers /> }],
public: [
{ path: '', element: <Servers /> },
{ path: 'servers/:id', element: <ServerDetail /> },
],
player: [{ path: '', element: <Account /> }],
})
// ── Nav ───────────────────────────────────────────────────────────────────
@@ -67,9 +90,45 @@ registry.registerRoutes(ID, {
// one is the only row in its sidebar with no glyph, which reads as breakage.
registry.registerNav(ID, {
area: 'public',
items: [{ label: 'Servers', to: '/rust/servers' }],
items: [{ label: 'Servers', to: '/rust' }],
})
// The player portal's row. It carries an `icon` because core draws one on every
// portal row — a row without one is the only text in a column of glyphs, and
// core used to render `<n.icon />` unguarded, which blanked the whole portal.
//
// No `order`: an unordered row appends after core's own rather than claiming a
// position it was not given. Account, appeals and notifications are what a player
// came to the portal for; linking a game account is what they do once.
registry.registerNav(ID, {
area: 'player',
items: [{ label: 'Rust', to: '/player/rust', icon: IconLink }],
})
// ── Extension slots ───────────────────────────────────────────────────────
//
// Core declares a slot, only core may declare one, and at most one module may
// fill it (§3.7). `site.footer.status` is the status-ish spot in core's footer
// info row: core owns the position and passes `linkStyle`; the label, the
// destination, the data and whether anything renders at all are the module's.
//
// It is a CLIENT slot and cannot be named in `module.json`'s `extensions` —
// that array is validated against the SERVER registry, and naming a client slot
// there fails the load outright with `unknown extension slot`. Phase 1 found
// that the hard way; the two halves of R13 are declared in different places on
// purpose.
registry.registerExtension(ID, 'site.footer.status', FooterStatus)
// R13's other slot, and the one that IS named in `module.json` — because it has
// a server half too (`server/router/admin/usersRust.router.js`). The two halves
// carry one name on purpose: a module that adds routes under
// `/api/v1/admin/users/:id` is the module with something to show on that page.
//
// Core passes `userId` and nothing else, so the component builds its own client
// for the routes the server half registered. It renders NOTHING for a user with
// no linked Steam account, which is most of them.
registry.registerExtension(ID, 'admin.users.detail', UserRustSections)
// `module.json`'s `coreApi` range was checked by the loader before this file was
// ever served, so there is nothing to re-check here. Log it anyway: a mismatch
// between the core that validated the manifest and the core that published this

View File

@@ -0,0 +1,116 @@
// ── A poll that keeps what it already had ─────────────────────────────────
//
// **Why this is not `useAsync`.** Core's hook (MODULE_API.md §3.4, and
// `client/src/lib/useAsync.js` in core) is `useState({loading:true,error:null,data:null})`
// re-run on a dependency change — and the first thing it does on every run is
// blank `data` and set `loading`. That is right for a page load and wrong for a
// poll: bumping a dependency every twenty seconds would clear the killfeed,
// render `<Loading />` in its place and re-fill it, four times a minute, for ever.
//
// So a poll needs a hook whose refresh is INVISIBLE when it succeeds. It keeps
// the previous rows on screen, replaces them when the new ones arrive, and keeps
// them *and* reports the error when the fetch fails — because a site whose whole
// premise is "it renders while the game is off" must not blank the page the
// first time a request does.
//
// `useAsync` is still the right hook for everything that loads once, and the
// pages here use it for exactly that. Bundling this beside it is the kit working
// as intended: the nine shared members are the chrome every module must share,
// not a ceiling on what a module may write.
//
// ── Two behaviours worth knowing ──────────────────────────────────────────
//
// 1. **A backgrounded tab does not poll.** Page Visibility, plus an immediate
// refresh when the viewer comes back — which is also the moment stale rows
// are most visible. A tab left open overnight is otherwise a request every
// twenty seconds until the laptop dies.
// 2. **`key` resets, dependencies do not.** Switching server or wipe SHOULD
// blank the rows: what is on screen belongs to a different question. That is
// what `key` is for, and it is separate from the interval.
import { useCallback, useEffect, useRef, useState } from 'react'
/**
* @param {() => Promise<any>} fetcher called with no arguments; must not throw synchronously
* @param {object} options
* @param {string} options.key changes when the QUESTION changes, blanking the answer
* @param {number} options.intervalMs 0 disables polling — the hook then loads once
* @param {boolean} options.enabled false while the page has nothing to ask about yet
*/
export function usePolled(fetcher, { key = '', intervalMs = 20000, enabled = true } = {}) {
const [state, setState] = useState({ data: null, error: null, loading: enabled, at: null })
// The fetcher is rebuilt on every render — it closes over props — and a hook
// that listed it as a dependency would restart its interval every render. The
// ref is how the timer keeps calling the CURRENT one without depending on it.
const latest = useRef(fetcher)
latest.current = fetcher
// Guards a reply from a question nobody is asking any more: a slow request
// whose page has moved on, or one still in flight at unmount.
const generation = useRef(0)
const run = useCallback(
async (mine) => {
try {
const data = await latest.current()
if (mine !== generation.current) return
setState({ data, error: null, loading: false, at: Date.now() })
} catch (error) {
if (mine !== generation.current) return
// `data` is carried forward deliberately. A failed refresh is a page that
// says "this is what we last knew, and it did not refresh", which is the
// same promise the server list makes about a game server being down.
setState((prev) => ({ data: prev.data, error, loading: false, at: prev.at }))
}
},
[],
)
const refresh = useCallback(() => run(generation.current), [run])
useEffect(() => {
generation.current += 1
const mine = generation.current
if (!enabled) {
setState({ data: null, error: null, loading: false, at: null })
return undefined
}
setState({ data: null, error: null, loading: true, at: null })
run(mine)
if (!intervalMs) return () => { generation.current += 1 }
let timer = null
const visible = () => typeof document === 'undefined' || document.visibilityState === 'visible'
const start = () => {
if (timer === null) timer = setInterval(() => run(mine), intervalMs)
}
const stop = () => {
if (timer !== null) { clearInterval(timer); timer = null }
}
const onVisibility = () => {
if (visible()) { run(mine); start() } else stop()
}
if (visible()) start()
if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVisibility)
return () => {
// Bumping the generation on teardown is what makes an in-flight reply from
// the old question land nowhere. Clearing the timer alone would not.
generation.current += 1
stop()
if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVisibility)
}
}, [key, intervalMs, enabled, run])
return { ...state, refresh }
}
export default usePolled

49
client/src/icons.jsx Normal file
View File

@@ -0,0 +1,49 @@
// ── The nav glyph for this module's player-portal row ─────────────────────
//
// `icon` is part of the nav-item contract (MODULE_API.md §3.3, 1.3.0): core
// renders whatever component a row carries, exactly as it renders its own rows'
// icons — and core's player portal draws a glyph on every row, so a row without
// one reads as breakage rather than as a design. The client suite asserts it.
//
// The public header is text buttons and carries no icons, which is why this file
// arrives with the player row and not before it.
//
// **The frame is copied from core's `PlayerPortalLayout`, deliberately and by
// copy rather than by import** — 16px, `currentColor`, stroke 2. Four attributes
// of presentation are not a component: putting them in the shared kit would
// freeze core's icon sizing into the contract, where changing it later would be a
// major bump. A module that wants to look like the nav it is in matches that nav.
const Icon = ({ children }) => (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
{children}
</svg>
)
/**
* A chain link — what the row is for.
*
* Not a gem, a person or a server: the portal's rows say what a player does
* there, and what a player does at `/player/rust` is link an account. Core's own
* neighbours are a gear (account), a shield (appeals) and a bell (notifications),
* so the row has to read as a verb in that company.
*/
export const IconLink = () => (
<Icon>
<path d="M10 13a5 5 0 007.07 0l2.83-2.83a5 5 0 00-7.07-7.07L11.5 4.5" />
<path d="M14 11a5 5 0 00-7.07 0L4.1 13.83a5 5 0 007.07 7.07L12.5 19.5" />
</Icon>
)
export default { IconLink }

178
client/src/lib/feed.js Normal file
View File

@@ -0,0 +1,178 @@
// ── One stored frame as one line of a feed ────────────────────────────────
//
// `GET /public/rust/servers/:id/events` answers rows shaped
// `{ id, kind, t, wipeId, steamId, frame }`, where `frame` is the whole frame
// the plugin emitted — this module stores what it is given and indexes only the
// columns it serves (PROTOCOL.md §8.4, and the `raw` column in schema.sql). So
// everything a killfeed line needs is in `frame`, under the names the plugin
// wrote, and this file is the one place that knows them.
//
// **It returns PARTS, not a sentence.** A component wants the names emphasised
// and the detail muted, and a function returning `"Alice killed Bob"` forces
// either a `dangerouslySetInnerHTML` or a re-parse. Parts also make this
// testable without a DOM, which is the whole reason it is not a component.
//
// ── The rule for an unknown kind ──────────────────────────────────────────
//
// It renders as itself. A later protocol adds kinds, an operator's module may be
// older than their game host, and a feed that DROPPED what it did not recognise
// would be a page that quietly says less than the truth. The server's allowlist
// has already decided this row may be seen (`server/catalogue.js`); what is left
// here is presentation, and the honest presentation of a kind we have no words
// for is its own name.
import { duration, prefab } from './format.js'
/**
* Kinds this feed asks for.
*
* `player.tally` is public and deliberately NOT here: it is an aggregate the
* plugin flushes every sixty seconds per active player (§8.6), so a feed
* including it would be mostly wood counts. It is the leaderboard's input, and
* the leaderboard is where it shows up.
*/
export const FEED_KINDS = Object.freeze([
'player.death',
'player.connected',
'player.disconnected',
'player.respawned',
'player.chat',
'server.wipe',
'server.initialized',
'server.shutdown',
])
/** The filters the feed offers, and the kinds each one asks the API for. */
export const FILTERS = Object.freeze([
{ id: 'all', label: 'Everything', kinds: FEED_KINDS },
{ id: 'kills', label: 'Kills', kinds: ['player.death'] },
{ id: 'chat', label: 'Chat', kinds: ['player.chat'] },
{
id: 'sessions',
label: 'Comings and goings',
kinds: ['player.connected', 'player.disconnected', 'player.respawned'],
},
{ id: 'server', label: 'Server', kinds: ['server.wipe', 'server.initialized', 'server.shutdown'] },
])
export function kindsFor(filterId) {
const filter = FILTERS.find((f) => f.id === filterId)
return (filter || FILTERS[0]).kinds
}
/**
* One row as `{ tone, actor, join, verb, subject, detail }`.
*
* `actor` and `subject` are names and are emphasised; `verb` and `detail` are
* prose. Any of them may be empty. `tone` is the row's category, for the small
* colour the component gives it — never for deciding what a row means.
*
* `join` is what goes between the actor and the verb, and it exists for exactly
* one case: chat. "Brannock see you in september" is not a sentence anybody
* writes, and putting the colon in the message would put presentation inside the
* text a player typed.
*/
export function describe(row) {
const frame = (row && row.frame) || {}
const name = frame.name || null
switch (row && row.kind) {
case 'player.death':
return death(frame, name)
case 'player.connected':
return { tone: 'join', actor: name, verb: 'connected', subject: null, detail: '' }
case 'player.disconnected':
return {
tone: 'leave',
actor: name,
verb: 'disconnected',
subject: null,
// Two optional halves, and the session is the interesting one: the plugin
// omits `sessionSec` for a player who was already on when it loaded, so an
// absent value means "unknown", never zero (§8.4's note, and OnPlayerDisconnected).
detail: [frame.reason || null, frame.sessionSec ? `after ${duration(frame.sessionSec)}` : null]
.filter(Boolean)
.join(' · '),
}
case 'player.respawned':
return { tone: 'join', actor: name, verb: 'respawned', subject: null, detail: '' }
case 'player.chat':
return {
tone: 'chat',
actor: name,
join: ': ',
// The message is the row, so it goes in `verb` where a component renders
// it unemphasised — and it is the one field on this wire a player chooses
// the bytes of. React escapes it; nothing here may ever stop doing that.
verb: frame.message || '',
subject: null,
detail: frame.channel && frame.channel !== 'Global' ? frame.channel : '',
}
case 'server.wipe':
return {
tone: 'server',
actor: null,
verb: 'The map was wiped',
subject: null,
detail: frame.wipeId ? `new wipe ${frame.wipeId}` : '',
}
case 'server.initialized':
return { tone: 'server', actor: null, verb: 'The server came up', subject: null, detail: '' }
case 'server.shutdown':
return { tone: 'server', actor: null, verb: 'The server went down', subject: null, detail: '' }
default:
return { tone: 'other', actor: name, verb: String((row && row.kind) || 'unknown'), subject: null, detail: '' }
}
}
/**
* A death, which is four different sentences.
*
* The plugin distinguishes `player`, `self`, `npc` and `environment` precisely so
* that a reader does not have to guess from an absent field, and collapsing any
* two of them loses something (see `DescribeAttacker` in the bridge plugin). A
* killfeed that reported a fall as a kill by nobody is the failure this avoids.
*/
function death(frame, name) {
const where = [
frame.weapon ? `with ${prefab(frame.weapon)}` : null,
frame.distance ? `${Math.round(frame.distance)}m` : null,
frame.grid || null,
frame.sleeping ? 'while sleeping' : null,
]
.filter(Boolean)
.join(' · ')
switch (frame.attackerType) {
case 'player':
return { tone: 'kill', actor: frame.attackerName || null, verb: 'killed', subject: name, detail: where }
case 'self':
return { tone: 'death', actor: name, verb: 'died by their own hand', subject: null, detail: where }
case 'npc':
return {
tone: 'death',
actor: prefab(frame.attackerName) || 'Something',
verb: 'killed',
subject: name,
detail: where,
}
// `environment` and anything else: falling, drowning, the world. `HitInfo`
// is legitimately null on this path, so an absent attacker type is this case
// rather than a missing field to complain about.
default:
return { tone: 'death', actor: name, verb: 'died', subject: null, detail: where }
}
}
export default { describe, FEED_KINDS, FILTERS, kindsFor }

136
client/src/lib/format.js Normal file
View File

@@ -0,0 +1,136 @@
// ── Formatting, with no dependencies and no React ─────────────────────────
//
// Every function here is pure and takes what the API answered, so the suite next
// door can ask all of it without a DOM. That is deliberate: the client half's
// real failures are timing and resolution (see `test/build.test.js`), which a
// DOM-less runner cannot see — so the way to have any test coverage at all on
// this side is to keep the parts that CAN be tested free of React.
//
// `Intl` does the work. It is in every browser core supports, it knows the
// viewer's locale and their clock, and it is one fewer thing in a chunk an
// operator ships.
const RELATIVE = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
const UNITS = [
['year', 31536000],
['month', 2592000],
['week', 604800],
['day', 86400],
['hour', 3600],
['minute', 60],
['second', 1],
]
/**
* "3 minutes ago", from an ISO string or an epoch-millisecond number.
*
* Both shapes arrive from this module's own API: `updatedAt` is an ISO string
* the model produced, and an event's `t` is the millisecond stamp the plugin put
* on the frame. Accepting both here is what stops every caller remembering which
* is which.
*/
export function ago(value, now = Date.now()) {
const at = toMillis(value)
if (at === null) return 'never'
const seconds = Math.round((at - now) / 1000)
const magnitude = Math.abs(seconds)
// Under a minute, "in 0 seconds" is what `numeric: 'auto'` produces and it is
// not what anybody means. Say the thing.
if (magnitude < 45) return 'just now'
const [unit, size] = UNITS.find(([, s]) => magnitude >= s) || ['second', 1]
return RELATIVE.format(Math.round(seconds / size), unit)
}
/**
* The stamp on a feed row.
*
* **Today's rows get a time; everything older gets a date as well.** The feed can
* be filtered to a past wipe, and a row from six weeks ago rendered as `02:03 PM`
* reads as this afternoon — which the page walk found the moment it looked at the
* previous wipe: three events from August, all apparently a few minutes old.
*
* `now` is a parameter so the boundary is testable rather than a property of the
* machine the test runs on.
*/
export function clock(value, now = Date.now()) {
const at = toMillis(value)
if (at === null) return ''
const when = new Date(at)
const time = when.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
const today = new Date(now)
const sameDay =
when.getFullYear() === today.getFullYear() &&
when.getMonth() === today.getMonth() &&
when.getDate() === today.getDate()
if (sameDay) return time
return `${when.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} ${time}`
}
/** A date, for a wipe: the thing people actually compare wipes by. */
export function day(value) {
const at = toMillis(value)
if (at === null) return 'unknown'
return new Date(at).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
}
/**
* A session or a playtime, as `4h 12m`.
*
* Seconds are dropped above a minute and kept below it, because a two-hour
* session reported to the second is noise and a forty-second one reported as
* "0m" is wrong.
*/
export function duration(seconds) {
const total = Number(seconds)
if (!Number.isFinite(total) || total <= 0) return '—'
if (total < 60) return `${Math.round(total)}s`
const hours = Math.floor(total / 3600)
const minutes = Math.round((total % 3600) / 60)
if (hours === 0) return `${minutes}m`
return minutes === 0 ? `${hours}h` : `${hours}h ${minutes}m`
}
/** Thousands separators, in the viewer's locale. */
export function count(value) {
const n = Number(value)
return Number.isFinite(n) ? n.toLocaleString() : '0'
}
/**
* A prefab short name as something readable — `patrolhelicopter` stays itself,
* `rifle.ak` becomes `rifle ak`.
*
* Deliberately a light touch rather than a lookup table. A table mapping every
* Rust prefab to a pretty name is a second copy of the game's item list that
* goes stale every wipe, and the short name is what a Rust player reads on their
* own server console anyway.
*/
export function prefab(name) {
if (!name) return ''
return String(name).replace(/[_.]+/g, ' ').trim()
}
/** A steam id, shortened for a table cell, without pretending it is a name. */
export function shortId(steamId) {
const id = String(steamId || '')
return id.length > 10 ? `${id.slice(-6)}` : id
}
function toMillis(value) {
if (value === null || value === undefined || value === '') return null
if (typeof value === 'number') return Number.isFinite(value) ? value : null
const parsed = Date.parse(value)
return Number.isNaN(parsed) ? null : parsed
}
export default { ago, clock, day, duration, count, prefab, shortId }

View File

@@ -0,0 +1,147 @@
// ── This module's fill for `admin.users.detail` ───────────────────────────
//
// R13's first slot, and the phase criterion as an operator meets it: the Steam
// id inside core's own user page, under core's own security panel.
//
// **The slot hands over `userId` and nothing else** — not a client. So this file
// builds its own bindings for the routes the server half registered
// (`api.adminUserLinks`), which is §3.5's rule applied to a slot: the two ends of
// a call belong to the same module even when the URL between them is core's.
//
// **Most users have no Rust account, so most of the time this renders nothing.**
// A panel that announced "no linked Steam accounts" on every user page in a
// community that also runs a UO shard would be noise on the overwhelming
// majority of them. Silence is the honest answer to "what does the Rust module
// know about this person" when it is nothing.
import { useCallback, useState } from 'react'
import { ago, count, duration } from '../../lib/format.js'
import { useAsync } from '../../core.js'
import api from '../../api.js'
/** Six lines of furniture the §3.4 kit does not carry, so it is vendored. */
function SectionTitle({ children }) {
return (
<div className="field-label" style={{ marginBottom: 12, marginTop: 4 }}>
{children}
</div>
)
}
/** One server's all-time totals for this player. */
function ServerRow({ server }) {
return (
<li
className="sans"
style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.86rem', color: 'var(--ink)' }}
>
<span style={{ minWidth: 0, color: 'var(--head)' }}>{server.serverName}</span>
<span className="dim" style={{ flex: 'none', fontSize: '0.8rem' }}>
{count(server.kills)} kills · {count(server.deaths)} deaths · {duration(server.playtimeSec)}
{server.wipes > 1 ? ` · ${server.wipes} wipes` : ''}
</span>
</li>
)
}
/** One linked Steam account: who it is, when it was linked, and the way out. */
function LinkPanel({ userId, link, onRemoved }) {
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
async function unlink() {
setBusy(true)
setError('')
try {
await api.adminUserLinks.remove(userId, link.steamId)
await onRemoved()
} catch (err) {
setError(err.message || 'Could not unlink that account.')
setBusy(false)
}
}
return (
<div className="panel" style={{ padding: '14px 16px' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 14 }}>
<div style={{ minWidth: 0, flex: 1 }}>
<div className="display" style={{ fontSize: '1rem', color: 'var(--head)' }}>
{link.name || link.steamId}
</div>
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
{link.steamId} · linked {ago(link.linkedAt)}
{link.serverId ? ` on ${link.serverId}` : ''}
{link.lastSeen ? ` · last played ${ago(link.lastSeen)}` : ' · never played'}
</div>
{/* Worth showing only when they differ: the name on the link is what
they were called when they linked, the other is what the game last
saw. A rename is the ordinary reason, and an operator reading a
support ticket wants both names. */}
{link.linkedName && link.name && link.linkedName !== link.name && (
<div className="sans dim" style={{ fontSize: '0.72rem', marginTop: 2 }}>
Linked as {link.linkedName}.
</div>
)}
</div>
<button type="button" className="btn ghost" onClick={unlink} disabled={busy} style={{ flex: 'none' }}>
{busy ? 'Unlinking…' : 'Unlink'}
</button>
</div>
{error && (
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.8rem', margin: '8px 0 0' }}>{error}</p>
)}
{link.servers.length > 0 && (
<ul
style={{
listStyle: 'none',
margin: '12px 0 0',
padding: '12px 0 0',
borderTop: '1px solid var(--line-soft)',
display: 'flex',
flexDirection: 'column',
gap: 6,
}}
>
{link.servers.map((server) => (
<ServerRow key={server.serverId} server={server} />
))}
</ul>
)}
</div>
)
}
export default function UserRustSections({ userId }) {
// Core's `useAsync` has no refresh, so a counter in the deps is how this
// re-reads after its own write (the same shape the player page uses).
const [reloads, setReloads] = useState(0)
const { data } = useAsync(() => api.adminUserLinks.list(userId), [userId, reloads])
const reload = useCallback(() => setReloads((n) => n + 1), [])
// No `Loading` and no `ErrorState`, deliberately. This is a section inside
// somebody else's page: a spinner on every user page for a module most users
// have nothing to do with is worse than a section that appears when it has
// something, and a failure here must not replace core's own user detail with an
// error card.
if (!data || data.links.length === 0) return null
return (
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
<SectionTitle>Rust</SectionTitle>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{data.links.map((link) => (
<LinkPanel key={link.steamId} userId={userId} link={link} onRemoved={reload} />
))}
</div>
<p className="sans dim" style={{ fontSize: '0.74rem', margin: '12px 0 0' }}>
A link is fleet-wide and totals are all-time, summed across every wipe. Unlinking here is
recorded in the activity log it is the way back for a player who linked the wrong account
and cannot reach it in game.
</p>
</section>
)
}

View File

@@ -0,0 +1,191 @@
// ── The player's own Rust identity ────────────────────────────────────────
//
// `/player/rust` — where a signed-in player links the Steam account they play
// on. It is the one page in this module a player is asked to *do* something on,
// and the thing they are doing matters more than it looks: from phase 7 the link
// is what in-game permissions are granted against, and from phase 13 it is what
// rewards are handed to.
//
// **A player route renders no layout of its own.** Core wraps `/player/*` in its
// own portal chrome, so this page starts at a heading — unlike the public pages
// in this module, which render `PublicLayout` themselves.
//
// The three-step instruction at the top is not decoration. Nothing else on the
// site tells a player that the code comes from the game, and a code field with no
// explanation is a code field nobody can use.
import { useCallback, useState } from 'react'
import { ErrorState, Loading, useAsync } from '../../core.js'
import { ago, shortId } from '../../lib/format.js'
import api from '../../api.js'
/** The code field, and the four answers it can produce. */
function LinkForm({ onLinked }) {
const [code, setCode] = useState('')
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState('')
const [error, setError] = useState('')
async function submit(event) {
event.preventDefault()
if (!code.trim() || busy) return
setBusy(true)
setMessage('')
setError('')
try {
const result = await api.playerLinks.confirm(code.trim())
setMessage(
result.already
? 'That account was already linked to you.'
: `Linked ${result.link.name || shortId(result.link.steamId)}.`,
)
setCode('')
await onLinked()
} catch (err) {
// Every refusal the server sends is already a sentence aimed at a player —
// "run /link again", "run /unlink in game", "try again in a minute" — so
// this renders it rather than replacing it with one of its own. The three
// are not interchangeable, and a page that flattened them into "could not
// link that code" would send a player back to the server that is down.
setError(err.message || 'Could not link that code.')
} finally {
setBusy(false)
}
}
return (
<form onSubmit={submit} style={{ marginTop: 18 }}>
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap' }}>
<label style={{ display: 'block' }}>
<span className="field-label" style={{ display: 'block', marginBottom: 6 }}>Link code</span>
<input
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())}
placeholder="K7M2PQ"
// The plugin's alphabet has no O, 0, I or 1, so a player reading a
// code off their screen cannot produce one but they can type a
// lowercase one, and the code is matched case-insensitively at the
// other end. Upper-casing here makes what they typed look like what
// they were shown.
maxLength={12}
autoComplete="off"
spellCheck={false}
className="input"
style={{ textTransform: 'uppercase', letterSpacing: '0.18em', width: 160 }}
/>
</label>
<button type="submit" className="btn" disabled={busy || !code.trim()}>
{busy ? 'Checking…' : 'Link account'}
</button>
</div>
{message && (
<p className="sans" style={{ color: '#7fd0a4', fontSize: '0.86rem', margin: '10px 0 0' }}>{message}</p>
)}
{error && (
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.86rem', margin: '10px 0 0' }}>{error}</p>
)}
</form>
)
}
/** One linked account, and the control that releases it. */
function LinkRow({ link, onRemoved }) {
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
async function remove() {
setBusy(true)
setError('')
try {
await api.playerLinks.remove(link.steamId)
await onRemoved()
} catch (err) {
setError(err.message || 'Could not unlink that account.')
setBusy(false)
}
}
return (
<li className="panel" style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}>
<div style={{ minWidth: 0, flex: 1 }}>
<div className="display" style={{ fontSize: '1rem', color: 'var(--head)' }}>
{link.name || shortId(link.steamId)}
</div>
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
{link.steamId} · linked {ago(link.linkedAt)}
{link.serverId ? ` on ${link.serverId}` : ''}
</div>
{error && (
<p className="sans" style={{ color: '#e05a5a', fontSize: '0.8rem', margin: '6px 0 0' }}>{error}</p>
)}
</div>
<button type="button" className="btn ghost" onClick={remove} disabled={busy} style={{ flex: 'none' }}>
{busy ? 'Unlinking…' : 'Unlink'}
</button>
</li>
)
}
export default function Account() {
// `useAsync` rather than this module's `usePolled`: nothing here changes unless
// the person looking at it changes it, and a page that re-asked every twenty
// seconds would be asking a question nobody is waiting on.
//
// **Core's `useAsync` has no `refresh`** — it re-runs when its deps change and
// that is the whole of its interface — so a counter in the deps is how a page
// re-reads after its own write. It blanks while it re-reads, which is right
// here and is exactly what made it wrong for a poll (see `hooks/usePolled.js`).
const [reloads, setReloads] = useState(0)
const { data, loading, error } = useAsync(() => api.playerLinks.list(), [reloads])
const links = data ? data.links : []
const reload = useCallback(() => setReloads((n) => n + 1), [])
return (
<div>
<div className="field-label" style={{ marginBottom: 12 }}>Steam accounts</div>
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem', maxWidth: '60ch' }}>
Linking tells this site which Steam account is yours, so your play on our servers appears
under your name here and so rewards and permissions the site hands out can reach you in
game.
</p>
<ol className="sans dim" style={{ fontSize: '0.86rem', marginTop: 14, paddingLeft: 20, maxWidth: '60ch' }}>
<li>Join any of our Rust servers and type <code>/link</code> in chat.</li>
<li>The server replies with a six-character code, only you can see it, and it lasts five minutes.</li>
<li>Type it below. It works once.</li>
</ol>
<LinkForm onLinked={reload} />
{loading && <Loading />}
{error && <ErrorState error={error} />}
{data && links.length > 0 && (
<ul style={{ listStyle: 'none', margin: '22px 0 0', padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
{links.map((link) => (
<LinkRow key={link.steamId} link={link} onRemoved={reload} />
))}
</ul>
)}
{data && links.length > 0 && (
<p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 14, maxWidth: '60ch' }}>
A link covers every server this community runs a Steam account is one person wherever
they play, while stats are kept per server and per wipe. You can also type
{' '}<code>/unlink</code> in game to release one.
</p>
)}
{data && links.length === 0 && (
<p className="sans dim" style={{ fontSize: '0.8rem', marginTop: 18 }}>
No Steam account is linked to this profile yet.
</p>
)}
</div>
)
}

View File

@@ -0,0 +1,184 @@
// ── One server ────────────────────────────────────────────────────────────
//
// R8's page beneath the landing page, and the phase-4 criterion lives here: it
// renders the last thing this server said while every server is off. Nothing on
// it is a live call to a game host — every panel reads this module's own tables,
// filled by the ingest cursor — so a shard that has been down for a week renders
// a week-old killfeed and a leaderboard that is still correct, rather than an
// error page.
//
// ── Everything selectable is in the URL ───────────────────────────────────
//
// Tab, feed filter, wipe and leaderboard sort all live in search parameters.
// That costs a little ceremony here and buys the thing a community site is for:
// "look at last wipe's leaderboard on Main" is a LINK. State held in `useState`
// would make every one of those sentences unlinkable, lose the reader's place on
// a refresh, and make the browser's back button leave the page instead of
// undoing what they just clicked.
//
// `useSearchParams` comes from CORE's router (the shim in `src/shim/`), so it is
// the same live navigation context core's own pages use. A module with its own
// copy of react-router would get a `useParams` that returns nothing on a page
// that otherwise renders perfectly — see `core.js`'s identity check.
import { useSearchParams, useParams, Link } from 'react-router-dom'
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
import Feed from '../../components/Feed.jsx'
import Leaderboard from '../../components/Leaderboard.jsx'
import Online from '../../components/Online.jsx'
import Tabs from '../../components/Tabs.jsx'
import WipeSelect, { ALL_TIME } from '../../components/WipeSelect.jsx'
import Wipes from '../../components/Wipes.jsx'
import { ago, count, day } from '../../lib/format.js'
import api from '../../api.js'
const TABS = [
{ id: 'feed', label: 'Feed' },
{ id: 'leaderboard', label: 'Leaderboard' },
{ id: 'online', label: 'Online' },
{ id: 'wipes', label: 'Wipes' },
]
export default function ServerDetail() {
const { id } = useParams()
const [params, setParams] = useSearchParams()
const { data, loading, error } = useAsync(() => api.servers.get(id), [id])
const server = data ? data.server : null
const tab = TABS.some((t) => t.id === params.get('tab')) ? params.get('tab') : 'feed'
const filter = params.get('show') || 'all'
const sort = params.get('sort') || 'kills'
// `wipe` absent means all time; `wipe=current` means whatever wipe the server
// is on now, which is a moving target and therefore a word rather than an id —
// a link somebody shares stays about "now" rather than about the map that was
// current when they sent it.
const wipeParam = params.get('wipe')
const wipeId = !wipeParam || wipeParam === ALL_TIME ? null : wipeParam === 'current' ? (server && server.wipeId) || null : wipeParam
const set = (key, value) => {
const next = new URLSearchParams(params)
if (!value || value === 'all' || (key === 'tab' && value === 'feed')) next.delete(key)
else next.set(key, value)
// `replace` so that flipping between tabs does not fill the reader's history
// with one entry per click — back should leave the page they arrived on.
setParams(next, { replace: true })
}
if (loading) {
return (
<PublicLayout shell="mid">
<Loading />
</PublicLayout>
)
}
// A 404 from the detail route is the one answer the other four cannot give:
// an unknown id has no events, no leaderboard and nobody online, and each of
// those empty lists is a perfectly good answer to its own question. So this is
// where "there is no such server" is said.
//
// **A mistyped address is not a fault, and must not be dressed as one.** The
// first version of this page rendered core's `ErrorState` under the heading and
// the result read "No such server / Something went wrong" — which sends a
// reader who fat-fingered a URL looking for an outage. `ErrorState` is kept for
// the case it is for: a request that failed for a reason nobody can see.
if (error || !server) {
const missing = !error || error.status === 404
return (
<PublicLayout shell="mid">
<PageHeader
title={missing ? 'No such server' : 'That server could not be loaded'}
lead={
missing
? 'This address does not name a server this site follows.'
: 'The site could not read this server just now. It is worth trying again.'
}
/>
{!missing && <ErrorState error={error} />}
<p className="sans" style={{ marginTop: 20 }}>
<Link to="/rust">Back to the server list</Link>
</p>
</PublicLayout>
)
}
return (
<PublicLayout shell="mid">
<PageHeader
eyebrow="Rust"
title={server.name}
lead={describeWorld(server)}
/>
<div
className="sans"
style={{ display: 'flex', flexWrap: 'wrap', gap: 16, alignItems: 'baseline', marginBottom: 24 }}
>
<span style={{ color: server.online ? 'var(--mode-live, #5fb98a)' : 'var(--dim)' }}>
{server.online
? `${count(server.players)}${server.maxPlayers ? ` / ${count(server.maxPlayers)}` : ''} online`
: 'Offline'}
</span>
{/* `lastSeenAt` is when a frame arrived; `updatedAt` is when this site
last wrote the row, which a FAILED poll does too. Reading the second
as the first is what made an offline server claim it had reported just
now, every thirty seconds, for as long as it stayed down. */}
<span style={{ color: 'var(--dim)', fontSize: '0.8rem' }}>
{server.lastSeenAt ? `last reported ${ago(server.lastSeenAt)}` : 'has never reported'}
{server.stale && server.lastSeenAt ? ' — out of date, so it is shown as offline' : ''}
</span>
<span style={{ marginLeft: 'auto' }}>
<WipeSelect
serverId={server.id}
value={wipeParam}
currentWipeId={server.wipeId}
onChange={(value) => set('wipe', value === ALL_TIME ? null : value)}
/>
</span>
</div>
<Tabs tabs={TABS} active={tab} onSelect={(next) => set('tab', next)} label={`${server.name} sections`} />
{tab === 'feed' && (
<Feed serverId={server.id} wipeId={wipeId} filter={filter} onFilter={(value) => set('show', value)} />
)}
{tab === 'leaderboard' && (
<Leaderboard serverId={server.id} wipeId={wipeId} sort={sort} onSort={(value) => set('sort', value)} />
)}
{tab === 'online' && <Online serverId={server.id} online={server.online} />}
{tab === 'wipes' && (
<Wipes
serverId={server.id}
currentWipeId={server.wipeId}
selected={wipeId}
// Picking a wipe here is a navigation as much as a filter: it is the
// question "what happened during that map", and the answer is the feed.
onSelect={(value) => {
const next = new URLSearchParams(params)
next.set('wipe', value)
next.delete('tab')
setParams(next, { replace: true })
}}
/>
)}
</PublicLayout>
)
}
/** The world line under the heading — the things a Rust player asks first. */
function describeWorld(server) {
const parts = [
server.level || null,
server.worldSize ? `size ${count(server.worldSize)}` : null,
server.seed ? `seed ${server.seed}` : null,
server.wipedAt ? `wiped ${day(server.wipedAt)}` : null,
].filter(Boolean)
return parts.length > 0 ? parts.join(' · ') : 'This server has not described itself yet.'
}

View File

@@ -1,43 +1,45 @@
// ── The server list ───────────────────────────────────────────────────────
// ── The server list, and the module's landing page ────────────────────────
//
// R8: the list is what `/rust` renders, and `/rust/servers/:id` hangs beneath
// it. The route is registered with an empty path in `entry.jsx` — core turns
// that into the module's own namespace root — so this page's address is the one
// an operator links to when they mean "our Rust servers".
//
// An ordinary React component. Nothing about being inside a module changes how
// you write one the only differences are where React comes from (core, via the
// you write one; the only differences are where React comes from (core, via the
// aliases in `vite.config.js`, so the import below looks completely normal and is
// not) and where the chrome comes from (`../../core.js`, the shared UI kit).
//
// **Render `PublicLayout` yourself.** Core wraps public routes in its maintenance
// gate and nothing else, so a page that omits the layout renders bare — no
// header, no footer, no site chrome — which looks like a bug and is the contract
// (§3.3). Admin and player routes are the other way round: core wraps those.
// **Render `PublicLayout` yourself, and pass a `shell`.** Core wraps public
// routes in its maintenance gate and nothing else, so a page that omits the
// layout renders bare; without a `shell` it renders full-bleed with the footer
// riding up underneath it. Name a width, never a class — the classes are core's
// (MODULE_API.md §3.3).
//
// **And pass a `shell`.** The layout is the chrome; `shell` is the body — the
// centred column, the vertical padding, and the thing that holds the footer at
// the bottom of the viewport. Widths are 'narrow', 'mid' and 'wide'; name a
// width, never a class, because the classes belong to core's stylesheet.
//
// This is the phase-1 version of the landing page R8 calls for. It lists servers
// and links nowhere yet — `/rust/servers/:id` is the next phase's work — so it is
// deliberately a table and not a design.
// **This page never calls a game server.** Every field it renders comes from
// this module's own tables, written by the ingest cursor, which is what lets it
// render "offline, last seen an hour ago" instead of an error page when a shard
// is down. The site's availability does not depend on the game's.
import { Link } from 'react-router-dom'
import { EmptyState, ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
import { ago, count, day } from '../../lib/format.js'
import api from '../../api.js'
// A relative time that does not need a date library. `Intl.RelativeTimeFormat`
// is in every browser core supports, and one fewer dependency in the chunk is
// one fewer thing an operator ships.
const RELATIVE = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
function ago(iso) {
if (!iso) return 'never'
const seconds = Math.round((new Date(iso).getTime() - Date.now()) / 1000)
const [unit, size] = Math.abs(seconds) < 3600 ? ['minute', 60] : ['hour', 3600]
return RELATIVE.format(Math.round(seconds / size), unit)
/** The "last reported" line, which has three cases and not one. */
function reported(server) {
if (!server.lastSeenAt) return 'This server has never reported.'
if (server.stale) return `Last reported ${ago(server.lastSeenAt)} — out of date, so it is shown as offline.`
return `Last reported ${ago(server.lastSeenAt)}.`
}
export default function Servers() {
// `useAsync` is core's fetch/loading/error hook, and the components below are
// its states. Using them rather than rolling your own is what makes a module
// page indistinguishable from a core one while it loads and while it fails.
//
// It loads once, deliberately. The DETAIL page polls, because that is where
// somebody watching a server sits; a list is a place people pass through.
const { data, loading, error } = useAsync(() => api.servers.list(), [])
const servers = data ? data.servers : []
@@ -66,37 +68,54 @@ export default function Servers() {
)}
{servers.length > 0 && (
<div style={{ display: 'grid', gap: '0.75rem' }}>
<div style={{ display: 'grid', gap: 12 }}>
{servers.map((server) => (
<div
// The whole row is the link. A server's name being the only clickable
// part is the thing people miss on a list of cards, and `a.card`
// already carries core's own hover treatment.
<Link
key={server.id}
to={`/rust/servers/${encodeURIComponent(server.id)}`}
className="card"
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'baseline',
gap: '1rem',
padding: '0.75rem 0',
borderBottom: '1px solid rgba(128,128,128,0.25)',
padding: '16px 20px',
}}
>
<div>
<strong>{server.name}</strong>
{server.level ? <span style={{ opacity: 0.7 }}> · {server.level}</span> : null}
<div style={{ opacity: 0.7, fontSize: '0.9em' }}>
{/* `stale` is a first-class part of the answer rather than
something the page infers from a timestamp. The server
decides what counts as stale, because the server is what
knows how often a sidecar is supposed to check in. */}
Last reported {ago(server.updatedAt)}
{server.stale ? ' — out of date, so it is shown as offline.' : '.'}
</div>
</div>
<div style={{ whiteSpace: 'nowrap' }}>
<span>
<strong style={{ color: 'var(--ink)' }}>{server.name}</strong>
<span className="sans" style={{ display: 'block', color: 'var(--dim)', fontSize: '0.78rem', marginTop: 4 }}>
{[
server.level || null,
server.worldSize ? `size ${count(server.worldSize)}` : null,
server.wipedAt ? `wiped ${day(server.wipedAt)}` : null,
]
.filter(Boolean)
.join(' · ')}
</span>
<span className="sans" style={{ display: 'block', color: 'var(--dim)', fontSize: '0.74rem', marginTop: 2 }}>
{/* `lastSeenAt`, never `updatedAt`. The second is when THIS
site last wrote the row — which a failed poll does too — so
a page reading it told a reader that a server down for three
days had reported just now. And `stale` is a first-class
part of the answer rather than something inferred from a
timestamp: the server decides what counts as stale, because
the server knows how often a sidecar is supposed to check in. */}
{reported(server)}
</span>
</span>
<span
className="sans"
style={{ whiteSpace: 'nowrap', color: server.online ? 'var(--mode-live, #5fb98a)' : 'var(--dim)' }}
>
{server.online
? `${server.players}${server.maxPlayers ? ` / ${server.maxPlayers}` : ''} online`
? `${count(server.players)}${server.maxPlayers ? ` / ${count(server.maxPlayers)}` : ''} online`
: 'Offline'}
</div>
</div>
</span>
</Link>
))}
</div>
)}

141
client/test/feed.test.js Normal file
View File

@@ -0,0 +1,141 @@
// ── The feed's sentences ──────────────────────────────────────────────────
//
// `lib/feed.js` is the one part of the client half with real branching in it, and
// it is pure on purpose so that a DOM-less runner can ask all of it. Everything
// here is a claim about what a reader sees for a given frame — which is exactly
// the kind of thing that rots silently, because a wrong killfeed line is still a
// killfeed line.
//
// The fixtures are the frames the bridge plugin actually emits (its
// `DescribeAttacker`, and PROTOCOL.md §8.4), not invented shapes.
import test from 'node:test'
import assert from 'node:assert/strict'
import { createRequire } from 'node:module'
import { describe, FEED_KINDS, FILTERS, kindsFor } from '../src/lib/feed.js'
const row = (kind, frame = {}) => ({ id: 1, kind, t: Date.now(), wipeId: 'w1', steamId: '7656', frame })
test('a player kill names the killer and the victim, in that order', () => {
const line = describe(row('player.death', {
name: 'Bob',
attackerType: 'player',
attackerName: 'Alice',
weapon: 'rifle.ak',
distance: 42.4,
grid: 'H7',
}))
assert.equal(line.tone, 'kill')
assert.equal(line.actor, 'Alice')
assert.equal(line.verb, 'killed')
assert.equal(line.subject, 'Bob')
assert.match(line.detail, /rifle ak/)
assert.match(line.detail, /42m/)
assert.match(line.detail, /H7/)
})
test('the four attacker types are four different sentences', () => {
// The plugin distinguishes them precisely so a reader does not have to guess
// from an absent field, and collapsing any two loses something: a fall reported
// as a kill by nobody is the failure this prevents.
const victim = { name: 'Bob' }
const npc = describe(row('player.death', { ...victim, attackerType: 'npc', attackerName: 'scientistnpc_full_any' }))
assert.equal(npc.actor, 'scientistnpc full any')
assert.equal(npc.subject, 'Bob')
const self = describe(row('player.death', { ...victim, attackerType: 'self' }))
assert.equal(self.actor, 'Bob')
assert.equal(self.subject, null)
assert.match(self.verb, /own hand/)
const environment = describe(row('player.death', { ...victim, attackerType: 'environment' }))
assert.equal(environment.actor, 'Bob')
assert.equal(environment.verb, 'died')
assert.equal(environment.subject, null)
// `HitInfo` is legitimately null on the environment path, so a death frame with
// NO attacker type at all is that case — not a missing field to render around.
const bare = describe(row('player.death', victim))
assert.equal(bare.verb, 'died')
assert.equal(bare.subject, null)
})
test('a sleeping victim is said to have been sleeping', () => {
const line = describe(row('player.death', { name: 'Bob', attackerType: 'player', attackerName: 'Alice', sleeping: true }))
assert.match(line.detail, /while sleeping/)
})
test('a disconnect with no session length says nothing about one', () => {
// The plugin OMITS `sessionSec` for a player who was already on when it loaded:
// an unknown session is not a session of no length. A line reading "after 0s"
// would be a lie this module invented.
const unknown = describe(row('player.disconnected', { name: 'Bob', reason: 'Quit' }))
assert.equal(unknown.detail, 'Quit')
const known = describe(row('player.disconnected', { name: 'Bob', reason: 'Quit', sessionSec: 3720 }))
assert.equal(known.detail, 'Quit · after 1h 2m')
})
test('a chat line carries the message as text, never as markup', () => {
// The message is the one field on this wire whose bytes a player chooses. It
// comes back as a STRING and is rendered as a React child, which escapes it;
// this test is here so that a later "render the message with formatting" idea
// has to delete an explicit assertion rather than quietly change behaviour.
const line = describe(row('player.chat', { name: 'Bob', message: '<img src=x onerror=alert(1)>', channel: 'Global' }))
assert.equal(line.verb, '<img src=x onerror=alert(1)>')
assert.equal(typeof line.verb, 'string')
// Global is the default channel and saying so on every line is noise; Team is
// information.
assert.equal(line.detail, '')
assert.equal(describe(row('player.chat', { name: 'B', message: 'hi', channel: 'Team' })).detail, 'Team')
// A chat row is the one line where the actor is a speaker rather than a
// subject, and "Brannock see you in september" is not a sentence anybody
// writes. The colon is presentation, so it lives here and not inside the text
// the player typed.
assert.equal(line.join, ': ')
assert.equal(describe(row('player.connected', { name: 'B' })).join, undefined)
})
test('an unknown kind renders as itself rather than vanishing', () => {
// A later protocol adds kinds, and a module may be older than the game host it
// is reading. The server's allowlist has already decided the row may be seen;
// dropping it here would make the page quietly say less than the truth.
const line = describe(row('player.teleported', { name: 'Bob' }))
assert.equal(line.verb, 'player.teleported')
assert.equal(line.tone, 'other')
})
test('the feed never asks for the aggregate kind', () => {
// `player.tally` is public and is flushed once a minute per active player
// (§8.6). A feed that included it would be mostly wood counts; it is the
// leaderboard's input, and that is where it shows up.
assert.ok(!FEED_KINDS.includes('player.tally'))
for (const filter of FILTERS) {
for (const kind of filter.kinds) {
assert.ok(FEED_KINDS.includes(kind), `filter "${filter.id}" asks for ${kind}, which the feed does not carry`)
}
}
})
test('every kind the feed asks for is one the public route will serve', () => {
// Held against the module's own allowlist rather than against a copy of it: a
// kind this file asked for and `server/catalogue.js` refuses is a filter that
// silently returns nothing, which reads as a quiet server.
//
// A CommonJS file from the server half, read by an ESM test through
// `createRequire`. Crossing the two halves is fine HERE and nowhere else:
// `test/` is not shipped, and `scripts/checkImports.js` governs what is.
const catalogue = createRequire(import.meta.url)('../../server/catalogue.js')
for (const kind of FEED_KINDS) {
assert.ok(catalogue.PUBLIC_KINDS.includes(kind), `the feed asks for ${kind}, which is not public`)
}
})
test('an unknown filter falls back to everything rather than to nothing', () => {
assert.deepEqual(kindsFor('nonsense'), FEED_KINDS)
assert.deepEqual(kindsFor(undefined), FEED_KINDS)
})

View File

@@ -0,0 +1,96 @@
// ── Formatting ────────────────────────────────────────────────────────────
//
// Small functions, and the tests are small too — but three of them guard claims
// that would otherwise be made by a page that looks fine: an unknown duration
// rendered as zero, a timestamp in the wrong unit, and "in 0 seconds".
//
// Locale-dependent output is asserted loosely on purpose. `Intl` formats to the
// RUNNER's locale, and a test pinned to "3 minutes ago" would be a test that
// fails on a machine set to French while the page it describes is correct.
import test from 'node:test'
import assert from 'node:assert/strict'
import { ago, clock, count, day, duration, prefab, shortId } from '../src/lib/format.js'
const NOW = Date.parse('2026-09-16T12:00:00Z')
test('a relative time picks the unit that fits', () => {
assert.match(ago(NOW - 3 * 60_000, NOW), /3/)
assert.match(ago(NOW - 5 * 3600_000, NOW), /5/)
assert.match(ago(NOW - 3 * 86400_000, NOW), /3/)
})
test('"just now" rather than "in 0 seconds"', () => {
// What `numeric: 'auto'` produces under a minute is not what anybody means,
// and a feed row a few seconds old is the commonest row on the page.
assert.equal(ago(NOW, NOW), 'just now')
assert.equal(ago(NOW - 10_000, NOW), 'just now')
})
test('both time shapes this module serves are accepted', () => {
// `updatedAt` is an ISO string the model produced; an event's `t` is the
// millisecond stamp the plugin put on the frame. A helper that took only one
// would be a helper every caller has to remember the type for.
assert.equal(ago('2026-09-16T11:57:00.000Z', NOW), ago(NOW - 3 * 60_000, NOW))
})
test('a missing time is "never", not the epoch', () => {
assert.equal(ago(null), 'never')
assert.equal(ago(undefined), 'never')
assert.equal(ago(''), 'never')
assert.equal(day(null), 'unknown')
})
test('an unknown duration is a dash, and a short one keeps its seconds', () => {
// The distinction the plugin makes and this must not lose: `sessionSec` is
// ABSENT for a player who was already on when it loaded, so zero and unknown
// arrive at the same function and must not render the same way.
assert.equal(duration(null), '—')
assert.equal(duration(0), '—')
assert.equal(duration(40), '40s')
assert.equal(duration(90), '2m')
assert.equal(duration(3720), '1h 2m')
assert.equal(duration(7200), '2h')
})
test('a prefab reads as words, without a lookup table', () => {
assert.equal(prefab('rifle.ak'), 'rifle ak')
assert.equal(prefab('scientistnpc_full_any'), 'scientistnpc full any')
assert.equal(prefab(null), '')
})
test('a steam id is shortened without pretending to be a name', () => {
assert.equal(shortId('76561198000000001'), '…000001')
assert.equal(shortId(''), '')
})
test('a count that is not a number is zero, never NaN on the page', () => {
assert.equal(count(undefined), '0')
assert.equal(count(null), '0')
})
test("a feed row from another day carries its date, not just a time", () => {
// Found by the page walk: with the feed filtered to the previous wipe, three
// events from six weeks ago rendered as `02:03 PM` and read as this afternoon.
// Today's rows stay bare, because a killfeed of today's fights does not want
// the date on every line.
// Asserted against `Intl` rather than against a literal: a 12-hour locale puts
// letters in a bare time ("05:30 AM"), so "has letters in it" is not the test —
// "is exactly the time, and nothing else" is.
const time = (at) => new Date(at).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
const todayAt = NOW - 90 * 60_000
assert.equal(clock(todayAt, NOW), time(todayAt))
const olderAt = NOW - 46 * 86400_000
assert.ok(clock(olderAt, NOW).endsWith(time(olderAt)))
assert.ok(clock(olderAt, NOW).length > time(olderAt).length, 'an older row carries no date')
// Yesterday counts as another day even when it is only a few hours back — the
// boundary is the calendar, not a duration, because that is what a reader
// means by "what time was that".
const lateLastNight = Date.parse('2026-09-15T23:50:00')
const earlyToday = Date.parse('2026-09-16T00:20:00')
assert.ok(clock(lateLastNight, earlyToday).length > time(lateLastNight).length)
})

View File

@@ -66,9 +66,22 @@ function fakeRg() {
),
api: { request: async () => ({}), ApiError: Error, BASE: '/api/v1' },
registry: {
// Core's own prefixing, character for character (client/src/modules/registry.js):
// the leading separators of the module's path are stripped and so are the
// TRAILING ones, which is what lets a module register `path: ''` and own its
// namespace root — `/rust` rather than `/rust/`.
//
// This fake did the obvious `${id}/${path}` until phase 4, and the day a
// module registered an index route it produced `rust/` while a real core
// produced `rust`. The suite then failed the nav check for a link that works
// perfectly in a browser. A fake that is nearly core is worse than one that
// is obviously not: it fails on the truth.
registerRoutes(id, byArea) {
for (const [area, list] of Object.entries(byArea || {})) {
for (const r of list || []) routes[area].push({ ...r, path: `${id}/${r.path}`, moduleId: id })
for (const r of list || []) {
const path = `${id}/${String(r.path || '').replace(/^\/+/, '')}`.replace(/\/+$/, '')
routes[area].push({ ...r, path, moduleId: id })
}
}
},
registerNav(id, { area, items }) {
@@ -120,7 +133,12 @@ it('registers at least one route, namespaced under the module id', () => {
assert.ok(all.length > 0, 'the chunk registered no routes at all')
for (const [area, list] of Object.entries(registered.routes)) {
for (const r of list) {
assert.ok(r.path.startsWith(`${manifest.id}/`), `${area} route "${r.path}" is not under the namespace`)
// Either the namespace root itself (a module's index route, `rust`) or
// something under it (`rust/servers/:id`). `startsWith('rust/')` alone
// would reject the root — and `startsWith('rust')` alone would accept a
// hypothetical `rustling`, which is why this is spelled out.
const under = r.path === manifest.id || r.path.startsWith(`${manifest.id}/`)
assert.ok(under, `${area} route "${r.path}" is not under the namespace`)
assert.ok(r.element, `${area} route "${r.path}" has no element`)
}
}
@@ -176,6 +194,19 @@ it('a nav row that gates on a feature has a provider to resolve it', () => {
assert.ok(registered.providers.size > 0, 'rows carry feature gates but no provider was registered')
})
it('the footer slot core declares is filled, and by a component', () => {
// R13's first slot, and the half that lives in the CHUNK: `site.footer.status`
// is a CLIENT slot, so it cannot be named in `module.json`'s `extensions` —
// that array is validated against the SERVER registry and naming a client slot
// there fails the load outright. Nothing else holds this registration, and an
// extension that stopped being registered is invisible: an unfilled slot
// renders nothing, exactly as an uninstalled module does.
const footer = registered.extensions.get('site.footer.status')
assert.ok(footer, 'nothing fills site.footer.status')
assert.equal(footer.id, manifest.id)
assert.equal(typeof footer.Component, 'function')
})
it('every slot module.json declares is one the chunk fills', () => {
// `module.json` declares SERVER slots, and the loader validates those before
// the chunk is ever served. Client slots cannot be declared there — the server

View File

@@ -12,5 +12,6 @@
"admin": ["/rust"],
"player": ["/rust"]
},
"capabilities": ["servers"]
"extensions": ["admin.users.detail"],
"capabilities": ["rust", "servers", "killfeed", "leaderboard", "presence", "wipes", "identity"]
}

View File

@@ -6,11 +6,31 @@
"path": "/api/v1/admin/rust/servers/:id",
"tier": "public"
},
{
"method": "DELETE",
"path": "/api/v1/admin/users/:id/rust/links/:steamId",
"tier": "public"
},
{
"method": "DELETE",
"path": "/api/v1/player/rust/links/:steamId",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/admin/rust/servers",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/admin/users/:id/rust/links",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/player/rust/links",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/player/rust/servers",
@@ -21,6 +41,11 @@
"path": "/api/v1/public/rust/servers",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/public/rust/servers/:id",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/public/rust/servers/:id/events",
@@ -46,6 +71,11 @@
"path": "/api/v1/admin/rust/servers/:id/test",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/player/rust/link",
"tier": "public"
},
{
"method": "PUT",
"path": "/api/v1/admin/rust/servers/:id",

View File

@@ -106,7 +106,12 @@ async function refreshOne(server) {
// whose plugin is not loaded yet, and reporting it as unreachable sends the
// operator to look at the network instead of at the game server.
if (!board.ok) {
await db.putState({ serverId: server.id, reachable: false, online: false })
// `markUnreachable`, not `putState`: nothing answered, so the only new fact
// is that nothing answered. Writing the whole row from that one fact would
// blank the hostname, the map, the seed and the wipe — the last thing this
// server said, which is exactly what the pages exist to render while it is
// off.
await db.markUnreachable(server.id, false)
return
}
@@ -117,7 +122,7 @@ async function refreshOne(server) {
// The sidecar is up and has never heard from the game. Presence is emptied
// rather than left alone: a stale list of players on a server nobody can
// reach is worse than an empty one, because it looks current.
await db.putState({ serverId: server.id, reachable: true, online: false })
await db.markUnreachable(server.id, true)
await ingest.applyBoards(server.id, {})
return
}

View File

@@ -69,9 +69,16 @@ const STAFF_KINDS = Object.freeze([
'player.unbanned',
'player.login.attempt',
'player.approved',
// Protocol 3's two account frames. Neither carries a code — the code travels
// through the player, which is what makes typing it proof — but both name a
// Steam id ALONGSIDE a website account's activity, which is exactly the join a
// public page must not be able to make: "this player is that person" is a fact
// about somebody's identity, not about what happened on the server.
'account.link.requested',
'account.unlinked',
])
/** Every kind protocol 2 defines. */
/** Every kind protocol 3 defines. */
const ALL_KINDS = Object.freeze([...PUBLIC_KINDS, ...STAFF_KINDS])
const PUBLIC = new Set(PUBLIC_KINDS)

View File

@@ -19,6 +19,7 @@
-- it knows this module registered, because it is the side that knows which
-- registrant owned what.
DROP TABLE IF EXISTS rust_account_links;
DROP TABLE IF EXISTS rust_ingest_cursor;
DROP TABLE IF EXISTS rust_presence;
DROP TABLE IF EXISTS rust_events;

View File

@@ -291,6 +291,50 @@ CREATE TABLE IF NOT EXISTS rust_ingest_cursor (
);
-- ── Who owns which Steam account ──────────────────────────────────────────
--
-- R1's identity link, and the reason it is a table rather than a column on
-- `rust_players`: a link is a fact about a WEBSITE USER that happens to be keyed
-- by a Steam id, and it outlives every row this module writes about play. A
-- column here would be null for the overwhelming majority of players and would
-- be deleted by any sweep that pruned inactive ones.
--
-- **Keyed on `steam_id` alone, fleet-wide.** `rust_players` already made that
-- call in protocol 2 and it is the truth of the thing: a Steam account is one
-- person across every server an operator runs, where stats are per server and
-- per wipe. Linking on one server links for the fleet, because there is nothing
-- else it could honestly mean.
--
-- **One Steam id, at most one user** — that is what the primary key buys, and it
-- is load-bearing rather than tidy. Phase 7 makes the site the author of who may
-- do what in game and phase 13 makes it the thing that hands out loot; both are
-- grants against a Steam id, and both assume the question "whose is this?" has
-- exactly one answer.
--
-- The reverse is deliberately NOT constrained: one website user may hold several
-- Steam accounts. People have a second account, or a family shares a site login,
-- and refusing that would be inventing a rule the game does not have.
--
-- `ON DELETE CASCADE` from `users`: a deleted account's links go with it. The
-- alternative is a row naming a user id that resolves to nobody, which every
-- read would then have to defend against.
CREATE TABLE IF NOT EXISTS rust_account_links (
steam_id VARCHAR(32) NOT NULL PRIMARY KEY,
user_id INT NOT NULL,
-- What the player was called in game when they linked. A display name, kept
-- so an operator reading the admin panel sees a person rather than a number;
-- never used to identify anybody, because a Rust name changes on a whim.
name VARCHAR(191) NULL,
-- Which server minted the code. Not part of the identity — the link is
-- fleet-wide — but an operator asking "where did this come from" has no other
-- way to find out, and a support conversation starts there.
server_id VARCHAR(64) NULL,
linked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_rust_links_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
KEY idx_rust_links_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ── Changes to tables that already shipped ────────────────────────────────
--
-- An ALTER below the CREATE, never an edit to it: `CREATE TABLE IF NOT EXISTS`
@@ -298,3 +342,14 @@ CREATE TABLE IF NOT EXISTS rust_ingest_cursor (
-- would reach fresh installs only — which is the worst possible distribution for
-- a schema change, because it works everywhere it is tested.
ALTER TABLE rust_server_state ADD COLUMN IF NOT EXISTS wipe_id VARCHAR(48) NULL;
-- Phase 4. `updated_at` is when THIS module last wrote the row, which is not the
-- same fact as when the server last said something — and the pages were reading
-- the first as if it were the second, so a server that had been down for three
-- days rendered "last reported just now" on every failed poll.
--
-- They are genuinely two facts and both are wanted: `updated_at` decides whether
-- the row is stale (a module that stopped polling must not leave a page claiming
-- a server is up), and `last_seen_at` is when a `server.hello` last arrived. Only
-- a successful refresh moves it.
ALTER TABLE rust_server_state ADD COLUMN IF NOT EXISTS last_seen_at DATETIME NULL;

View File

@@ -50,6 +50,7 @@ module.exports = function register(ctx, api) {
const publicRust = require('./router/public/rust.router')
const playerRust = require('./router/player/rust.router')
const adminRust = require('./router/admin/rust.router')
const usersRust = require('./router/admin/usersRust.router')
const boot = require('./boot')
/* eslint-enable global-require */
@@ -78,6 +79,20 @@ module.exports = function register(ctx, api) {
admin: { '/rust': adminRust },
})
// R13's first extension slot (§2.4). Core declares `admin.users.detail` on
// `/api/v1/admin/users/:id` and we fill it; the router receives the parent's
// `req.params.id` through `mergeParams`. Core's own routes on the resource are
// declared before the slot is mounted, so core wins any path conflict — it owns
// the user, and this module owns what it can say about one.
//
// **It is declared twice, in two different places, on purpose.** This call is
// the SERVER half and `module.json`'s `extensions` array is held against it by
// the loader. The CLIENT half is `registry.registerExtension(ID,
// 'admin.users.detail', …)` in `entry.jsx` and must NOT appear in that array —
// phase 1 found that the hard way with `site.footer.status`, which is a client
// slot and fails the load outright when named there.
api.registerExtension('admin.users.detail', usersRust)
// The lifecycle hooks (§2.5). `onBoot` runs after core's schema, after this
// module's schema fragment, and BEFORE the HTTP listener binds — so a module
// that must not serve traffic until it has warmed a cache gets that for free.
@@ -92,14 +107,15 @@ module.exports = function register(ctx, api) {
// Everything else this module will register — the Team provider, the event
// triggers and audiences, the engagement seeds, the four event catalogues, the
// notification streams, the slash commands and the two extension slots — is
// deliberately absent. Each arrives with the phase that has something real to
// put in it. A registration with nothing behind it is worse than a missing one:
// a declared trigger nothing emits and a declared slot nothing fills are both
// surfaces an operator can configure and then wait on.
// notification streams and the slash commands — is deliberately absent. Each
// arrives with the phase that has something real to put in it. A registration
// with nothing behind it is worse than a missing one: a declared trigger
// nothing emits and a declared slot nothing fills are both surfaces an operator
// can configure and then wait on.
log.info('registered', {
version: require('../module.json').version,
routes: 'public:/rust player:/rust admin:/rust',
extensions: 'admin.users.detail',
})
}

View File

@@ -34,6 +34,7 @@
const core = require('./core')
const db = require('./model/events/events.db')
const links = require('./model/links/links.model')
const sidecar = require('./sidecarClient')
const log = core.logger('ingest')
@@ -144,6 +145,34 @@ async function apply(serverId, item) {
await db.touchPlayer(frame.steamId, frame.name || null)
break
// ── Protocol 3: the one frame that changes something other than a counter ──
//
// `/unlink` in game severs the site's link, and it is the only way out of a
// link on the wrong account: the site REFUSES to move a Steam id another
// website account already holds (D23), so without this a player who linked
// while signed in as the wrong account would need staff.
//
// It arrives here rather than through a route because the plugin has nothing
// to delete — the site is the author of record and the game holds no link —
// so `/unlink` is the game reporting what the player asked for, applied off
// the feed like every other frame.
//
// **The authority is the Steam account itself.** Whoever is connected to the
// game as it is who it is, which is a stronger proof of ownership than the
// site can obtain any other way, so this is not scoped by website user.
case 'account.unlinked':
await db.touchPlayer(frame.steamId, frame.name || null)
await links.unlinkFromGame(frame.steamId)
break
// Stored and counted as a sighting, nothing more. The code is deliberately
// NOT on this frame — it travels through the player — so there is nothing
// here to redeem and no pending state for the site to hold. It exists so an
// operator can see linking being used at all.
case 'account.link.requested':
await db.touchPlayer(frame.steamId, frame.name || null)
break
default:
// Stored, not counted. Moderation frames, the server lifecycle, and
// anything a newer protocol sends that this build does not understand.

View File

@@ -0,0 +1,159 @@
// ── SQL, and nothing else ─────────────────────────────────────────────────
//
// The `.db.js` half of the pair (see `servers.db.js` for why the split earns its
// keep). Raw parameterised SQL through `core.query`, placeholders always.
const core = require('../../core')
const LINKS = 'rust_account_links'
const PLAYERS = 'rust_players'
const STATS = 'rust_player_wipe_stats'
/**
* The link for one Steam id, or undefined.
*
* Joins core's `users` for the username, because every caller that asks "who
* owns this?" wants a name rather than an integer — and the one caller that
* refuses a re-link has to be able to say *whose* it is.
*/
async function getBySteamId(steamId) {
const rows = await core.query(
`SELECT l.steam_id AS steamId, l.user_id AS userId, l.name, l.server_id AS serverId,
l.linked_at AS linkedAt, u.username
FROM ${LINKS} l
JOIN users u ON u.id = l.user_id
WHERE l.steam_id = ?`,
[steamId],
)
return rows[0]
}
/**
* Every Steam account one website user holds, newest first.
*
* **It joins `rust_players` for the name the game last saw**, and that is not a
* convenience. The name on the LINK is what the player was called at the moment
* they linked, which is a Rust name and changes on a whim — so a player who has
* renamed since sees a name they no longer use, on the one page of the site that
* is about who they are. The admin panel already preferred the newer one; this
* is the same rule applied where the person themselves is reading.
*
* A LEFT JOIN, because a player can link an account and never play on it.
*/
async function listForUser(userId) {
return core.query(
`SELECT l.steam_id AS steamId, l.user_id AS userId, l.name, l.server_id AS serverId,
l.linked_at AS linkedAt, p.name AS playerName
FROM ${LINKS} l
LEFT JOIN ${PLAYERS} p ON p.steam_id = l.steam_id
WHERE l.user_id = ?
ORDER BY l.linked_at DESC`,
[userId],
)
}
/**
* Record a link.
*
* **A plain INSERT, never an upsert**, and that is the whole of D23 expressed in
* SQL. `ON DUPLICATE KEY UPDATE` here would silently move a Steam id from one
* website account to another — which, once phase 7 makes a link a privilege path
* and phase 13 makes it an entitlement, is an account takeover performed by
* typing a six-character code. The duplicate-key error is the refusal, and the
* controller turns it into a sentence.
*/
async function insert({ steamId, userId, name, serverId }) {
await core.query(
`INSERT INTO ${LINKS} (steam_id, user_id, name, server_id)
VALUES (?, ?, ?, ?)`,
[steamId, userId, name || null, serverId || null],
)
}
/**
* Remove a link the caller owns.
*
* Scoped by `user_id` in the statement rather than checked before it: a delete
* that reads, decides, then writes has a gap between the read and the write, and
* this way the ownership test and the deletion are the same operation. Answers
* how many rows went, so a caller can tell "removed" from "was not yours".
*/
async function removeOwned(steamId, userId) {
const result = await core.query(
`DELETE FROM ${LINKS} WHERE steam_id = ? AND user_id = ?`,
[steamId, userId],
)
return Number(result && result.affectedRows) || 0
}
/**
* Remove a link whoever holds it — the in-game `/unlink` path, and the staff
* unlink on the `admin.users.detail` panel (D25).
*
* Unscoped by user on purpose: neither caller is the link's owner and both have
* already established their authority another way. In game the authority is the
* Steam account itself — whoever is connected as it is who it is; on the admin
* panel it is the tier gate. Which is why the admin caller writes an
* `activity.log` entry naming the operator and this does not: it cannot tell the
* two apart, and a log line that guessed would be worse than none.
*/
async function removeBySteamId(steamId) {
const result = await core.query(`DELETE FROM ${LINKS} WHERE steam_id = ?`, [steamId])
return Number(result && result.affectedRows) || 0
}
/**
* Every link one user holds, enriched with what this module knows about that
* player — for the `admin.users.detail` panel.
*
* A LEFT JOIN, because a player can link an account and never play on it. An
* operator looking at that user should see the link, not an empty panel.
*/
async function listForUserWithPlayer(userId) {
return core.query(
`SELECT l.steam_id AS steamId, l.name, l.server_id AS serverId, l.linked_at AS linkedAt,
p.name AS playerName, p.first_seen AS firstSeen, p.last_seen AS lastSeen
FROM ${LINKS} l
LEFT JOIN ${PLAYERS} p ON p.steam_id = l.steam_id
WHERE l.user_id = ?
ORDER BY l.linked_at DESC`,
[userId],
)
}
/**
* Per-server all-time totals for one Steam id.
*
* The same rows the public leaderboard sums, grouped by server instead of
* filtered to one — so an operator sees a player across the fleet in one read.
* All-time, deliberately: an admin looking at a user wants their history, not
* this week's.
*/
async function statsForSteamId(steamId) {
return core.query(
`SELECT s.server_id AS serverId, srv.name AS serverName,
SUM(s.kills) AS kills,
SUM(s.deaths) AS deaths,
SUM(s.npc_kills) AS npcKills,
SUM(s.structures) AS structures,
SUM(s.playtime_sec) AS playtimeSec,
MAX(s.last_seen) AS lastSeen,
COUNT(DISTINCT s.wipe_id) AS wipes
FROM ${STATS} s
LEFT JOIN rust_servers srv ON srv.id = s.server_id
WHERE s.steam_id = ?
GROUP BY s.server_id, srv.name
ORDER BY SUM(s.playtime_sec) DESC`,
[steamId],
)
}
module.exports = {
getBySteamId,
listForUser,
listForUserWithPlayer,
insert,
removeOwned,
removeBySteamId,
statsForSteamId,
}

View File

@@ -0,0 +1,258 @@
// ── Who owns which Steam account ──────────────────────────────────────────
//
// R1's identity link, site-side. The flow it sits in the middle of:
//
// 1. In game, a player types `/link`. The plugin mints a one-time code, tells
// them privately, and holds it in memory for five minutes.
// 2. On the website, the player types that code. This module asks the sidecar,
// which asks the plugin, which answers with the Steam id the code belongs
// to and drops it.
// 3. This file records the result.
//
// **The site is the author of record and the game holds nothing.** That is the
// one real difference from the UO bridge, which writes a tag onto the game
// account: there is no equivalent per-account store in Rust that survives a wipe,
// and phase 7 needs the site to be authoritative anyway — it pushes permissions
// INTO the game keyed by Steam id. A copy in the game would be a second thing to
// reconcile every wipe, for no question it could answer better.
const core = require('../../core')
const db = require('./links.db')
const servers = require('../servers/servers.model')
const sidecar = require('../../sidecarClient')
const log = core.logger('links')
/** What a link looks like to any caller. Never carries a raw code. */
function shape(row) {
if (!row) return null
return {
steamId: row.steamId,
name: row.name || null,
serverId: row.serverId || null,
linkedAt: row.linkedAt,
}
}
/**
* The Steam accounts one website user holds.
*
* The name is the one the GAME last saw, falling back to the one recorded when
* they linked — the rule the admin panel already used, applied on the page the
* player themselves reads. A browser walk found the two disagreeing: staff saw
* `Wanderer` and the player saw `Wanderer-old`, for the same person on the same
* site.
*/
async function listForUser(userId) {
return (await db.listForUser(userId)).map((row) => ({
...shape(row),
name: row.playerName || row.name || null,
}))
}
/** True when this user holds this Steam id. The ownership gate every player read uses. */
async function owns(steamId, userId) {
const row = await db.getBySteamId(steamId)
return Boolean(row && Number(row.userId) === Number(userId))
}
/**
* Redeem a code against one server, and record the link.
*
* Answers a discriminated result rather than throwing, because every outcome
* here is a sentence somebody has to read:
*
* `{ ok: true, link }` — linked
* `{ ok: false, reason: 'rejected' }`— the game says that code is not good
* `{ ok: false, reason: 'taken', username }` — someone else holds that Steam id
* `{ ok: false, reason: 'offline' }` — the game or its sidecar did not answer
*
* **`rejected` deliberately collapses "unknown" and "expired".** The plugin
* distinguishes them and an operator reading its log can too; a stranger typing
* codes must not learn which of the two they hit, because that is the difference
* between "keep guessing" and "guess faster".
*/
async function confirmOne({ server, code, userId }) {
const result = await sidecar.confirmLink(server, code)
// The transport failed: the sidecar is unreachable, the game is not connected,
// or the reply never came. None of those is a verdict on the code, so the
// player is told to try again rather than that their code is wrong.
if (!result.ok) {
log.warn('link confirm did not reach the game', { server: server.id, status: result.status })
return { ok: false, reason: 'offline' }
}
const frame = result.data || {}
// The plugin's own refusal. `frame.reason` is `unknown`, `expired` or
// `malformed`; it is logged and not surfaced (see the doc above).
if (frame.kind !== 'link.ok' || !frame.steamId) {
log.info('link code refused', { server: server.id, reason: frame.reason || frame.kind || 'unknown' })
return { ok: false, reason: 'rejected' }
}
const steamId = String(frame.steamId)
const held = await db.getBySteamId(steamId)
// D23: refuse, and say whose it is. A move would transfer every permission and
// entitlement phases 7 and 13 hang off this link, on a code anybody in game
// could have run — and the player's way out is `/unlink` in game, which they
// can reach from the machine they are sitting at.
if (held) {
if (Number(held.userId) === Number(userId)) {
// Already theirs. Not an error: a player who pressed the button twice, or
// one whose code was confirmed on a request that then timed out.
return { ok: true, link: shape(held), already: true }
}
return { ok: false, reason: 'taken', username: held.username }
}
try {
await db.insert({
steamId,
userId,
name: frame.name || null,
serverId: server.id,
})
} catch (err) {
// The race the PRIMARY KEY exists for: two confirmations of the same Steam
// id, interleaved between the check above and this write. The key refuses the
// second and it becomes the same refusal, rather than a 500.
if (err && (err.code === 'ER_DUP_ENTRY' || err.errno === 1062)) {
const now = await db.getBySteamId(steamId)
if (now && Number(now.userId) === Number(userId)) {
return { ok: true, link: shape(now), already: true }
}
return { ok: false, reason: 'taken', username: now && now.username }
}
throw err
}
const link = shape(await db.getBySteamId(steamId))
log.info('steam account linked', { steamId, userId, server: server.id })
return { ok: true, link }
}
/**
* Redeem a code against the fleet (D24).
*
* **A code is minted by ONE server and the player types six characters into a
* browser**, so the site cannot know which server it came from — nothing in the
* code says, and asking the player to pick would make a wrong guess
* indistinguishable from a wrong code, which is the one refusal that must not be
* ambiguous. So every enabled server is asked in turn and the first `link.ok`
* wins. The others answer `unknown` and nothing happens there: a code is only
* spent at the server that actually holds it.
*
* The loop stops early on `taken`, because that is a verdict about the Steam id
* rather than about this server — asking the rest of the fleet would produce the
* same answer more slowly.
*
* **"Every reachable server refused" is not the same answer as "a server was
* unreachable"**, and collapsing them is how a player who linked on the one
* server that is down gets told their code is wrong. `unsure` is that case, and
* the sentence it earns says to try again rather than to run `/link` again.
*/
async function redeem({ code, userId }) {
const fleet = await servers.listForPolling()
if (fleet.length === 0) return { ok: false, reason: 'no-servers' }
let refused = 0
let unreachable = 0
for (const server of fleet) {
// Sequential, deliberately. In parallel every server would be asked even
// after one had already answered, and a code spent on the right server would
// still be travelling to five others — for a fleet of six and a five-minute
// TTL, there is nothing to win by racing them.
// eslint-disable-next-line no-await-in-loop
const result = await confirmOne({ server, code, userId })
if (result.ok || result.reason === 'taken') return result
if (result.reason === 'offline') unreachable += 1
else refused += 1
}
if (refused === 0) return { ok: false, reason: 'offline' }
if (unreachable > 0) return { ok: false, reason: 'unsure' }
return { ok: false, reason: 'rejected' }
}
/** Remove a link the caller owns. False when they did not hold it. */
async function unlinkOwned(steamId, userId) {
return (await db.removeOwned(steamId, userId)) > 0
}
/**
* Remove a link whoever holds it.
*
* Two callers, both of which have already established their authority and
* neither of which is the link's owner: ingest applying an in-game `/unlink`
* (the authority is the Steam account — whoever is connected as it is who it
* is), and a staff unlink from the `admin.users.detail` panel (D25).
*
* It logs nothing about who asked, because the two callers record that
* differently: the admin one writes an `activity.log` entry naming the operator,
* and the game one has no operator to name.
*/
async function unlinkAnyOwner(steamId) {
return (await db.removeBySteamId(steamId)) > 0
}
/**
* Remove a link because the player asked in game.
*
* Called from ingest, off an `account.unlinked` event.
*/
async function unlinkFromGame(steamId) {
const removed = await unlinkAnyOwner(steamId)
if (removed) log.info('steam account unlinked in game', { steamId })
return removed
}
/** The admin panel's read: every link this user holds, with per-server totals. */
async function forAdmin(userId) {
const links = await db.listForUserWithPlayer(userId)
return Promise.all(
links.map(async (row) => ({
steamId: row.steamId,
// The name on the LINK is what they were called when they linked; the one
// on `rust_players` is what the game last saw. They differ the moment
// somebody renames, and the newer one is the useful one to show.
name: row.playerName || row.name || null,
linkedName: row.name || null,
serverId: row.serverId || null,
linkedAt: row.linkedAt,
firstSeen: row.firstSeen || null,
lastSeen: row.lastSeen || null,
servers: (await db.statsForSteamId(row.steamId)).map((s) => ({
serverId: s.serverId,
serverName: s.serverName || s.serverId,
kills: Number(s.kills) || 0,
deaths: Number(s.deaths) || 0,
npcKills: Number(s.npcKills) || 0,
structures: Number(s.structures) || 0,
playtimeSec: Number(s.playtimeSec) || 0,
wipes: Number(s.wipes) || 0,
lastSeen: s.lastSeen || null,
})),
})),
)
}
module.exports = {
shape,
listForUser,
owns,
confirmOne,
redeem,
unlinkOwned,
unlinkAnyOwner,
unlinkFromGame,
forAdmin,
}

View File

@@ -80,11 +80,56 @@ async function listState() {
`SELECT server_id AS serverId, reachable, online, players, max_players AS maxPlayers,
hostname, level, seed, world_size AS worldSize, boot_id AS bootId,
save_created_at AS saveCreatedAt, wipe_id AS wipeId, protocol,
updated_at AS updatedAt
last_seen_at AS lastSeenAt, updated_at AS updatedAt
FROM ${STATE}`,
)
}
/** One server's observed state, or `null`. The single-row twin of `listState`. */
async function getState(serverId) {
const rows = await core.query(
`SELECT server_id AS serverId, reachable, online, players, max_players AS maxPlayers,
hostname, level, seed, world_size AS worldSize, boot_id AS bootId,
save_created_at AS saveCreatedAt, wipe_id AS wipeId, protocol,
last_seen_at AS lastSeenAt, updated_at AS updatedAt
FROM ${STATE}
WHERE server_id = ?`,
[serverId],
)
return rows[0] || null
}
/**
* Mark a server unreachable **without forgetting what it last said**.
*
* `putState` replaces the row whole, which is right when a sidecar answered: the
* frame it answered with is the complete truth about that server. It is wrong
* when nothing answered. A refresh that cannot reach a sidecar knows exactly one
* new fact — that it could not reach it — and writing the whole row from that
* one fact sets `hostname`, `level`, `seed`, `world_size` and `wipe_id` to NULL.
*
* The site's whole premise is that it renders the last thing each server said
* while every server is off. A row blanked the first time a game host reboots
* cannot do that: the page loses the map, the size, the seed and the wipe, and
* what it shows is not "offline, here is what we know" but "offline, and we have
* never heard of it". It is invisible in every test that stubs a reachable
* sidecar, and it shows up as a page that was complete an hour ago.
*
* So: three columns move, and the description stays where it is.
*/
async function markUnreachable(serverId, reachable = false) {
await core.query(
`INSERT INTO ${STATE} (server_id, reachable, online, players, updated_at)
VALUES (?, ?, 0, 0, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE
reachable = VALUES(reachable),
online = 0,
players = 0,
updated_at = CURRENT_TIMESTAMP`,
[serverId, reachable ? 1 : 0],
)
}
/**
* Replace one server's observed state.
*
@@ -100,15 +145,20 @@ async function putState(state) {
await core.query(
`INSERT INTO ${STATE}
(server_id, reachable, online, players, max_players, hostname, level, seed,
world_size, boot_id, save_created_at, wipe_id, protocol, raw, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
world_size, boot_id, save_created_at, wipe_id, protocol, raw, last_seen_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE
reachable = VALUES(reachable), online = VALUES(online), players = VALUES(players),
max_players = VALUES(max_players), hostname = VALUES(hostname), level = VALUES(level),
seed = VALUES(seed), world_size = VALUES(world_size), boot_id = VALUES(boot_id),
save_created_at = VALUES(save_created_at), wipe_id = VALUES(wipe_id),
protocol = VALUES(protocol),
raw = VALUES(raw), updated_at = CURRENT_TIMESTAMP`,
raw = VALUES(raw),
-- Only a frame moves this; an unreachable write leaves it alone, which is
-- what lets a page say how long a server has been down rather than how
-- recently we failed to reach it.
last_seen_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP`,
[
state.serverId,
state.reachable ? 1 : 0,
@@ -136,5 +186,7 @@ module.exports = {
upsertServer,
deleteServer,
listState,
getState,
markUnreachable,
putState,
}

View File

@@ -73,6 +73,7 @@ async function listPublic(now = Date.now()) {
function shapePublic(row, state, now) {
const updatedAt = state && state.updatedAt ? new Date(state.updatedAt) : null
const lastSeenAt = state && state.lastSeenAt ? new Date(state.lastSeenAt) : null
const stale = !updatedAt || now - updatedAt.getTime() > STALE_AFTER_MS
return {
@@ -87,11 +88,43 @@ function shapePublic(row, state, now) {
level: (state && state.level) || null,
worldSize: state && state.worldSize != null ? Number(state.worldSize) : null,
seed: state && state.seed != null ? Number(state.seed) : null,
// The CURRENT wipe, from the state row rather than from the newest row in
// `rust_wipes`. The two usually agree and the state row is the one that is
// right when they do not: a wipe list is derived from events that have been
// ingested, so a server that has just wiped and said nothing since has a new
// wipe id here and no row there at all.
wipeId: (state && state.wipeId) || null,
wipedAt: (state && state.saveCreatedAt) || null,
// Two timestamps, because they are two facts. `lastSeenAt` is when a frame
// last arrived and is what a page means by "last reported"; `updatedAt` is
// when this module last wrote the row, and is what `stale` is computed from.
// Reading the second as the first is what made an offline server claim it had
// reported just now, on every failed poll, for as long as it stayed down.
lastSeenAt: lastSeenAt ? lastSeenAt.toISOString() : null,
updatedAt: updatedAt ? updatedAt.toISOString() : null,
stale,
}
}
/**
* One enabled server, or `null`.
*
* It exists because `/rust/servers/:id` is a page and a page needs to be able to
* 404. A detail view built by fetching the list and finding the row in it cannot
* tell "no such server" from "a server that has said nothing" — both are an
* absence — and renders an empty page under a heading for a server that does not
* exist. Filtering happens here, where `enabled = 0` and "never configured" are
* the same answer on purpose: a disabled server is not a 403, it is not there.
*/
async function getPublic(id, now = Date.now()) {
if (!id) return null
const row = await db.getServer(id)
if (!row || !row.enabled) return null
return shapePublic(row, await db.getState(row.id), now)
}
/**
* The admin view: configuration plus reachability, and **no token**.
*
@@ -133,6 +166,7 @@ module.exports = {
withToken,
listForPolling,
listPublic,
getPublic,
listForAdmin,
shapePublic,
encryptToken,

View File

@@ -23,7 +23,7 @@ async function listServers(req, res) {
res.json({ servers: await servers.listForAdmin() })
} catch (err) {
log.error('failed to read the server list', { error: err.message })
res.status(500).json({ error: 'Failed to read the server list' })
res.status(500).json({ message: 'Failed to read the server list' })
}
}
@@ -39,7 +39,7 @@ async function putServer(req, res) {
// Refusing it up front costs one round trip and saves that hunt. An EXISTING
// row is a different case: omitting the token is how you say "leave it".
if (!existing && !sidecarToken) {
return res.status(400).json({ error: 'A new server needs its sidecar token' })
return res.status(400).json({ message: 'A new server needs its sidecar token' })
}
await db.upsertServer({
@@ -71,7 +71,7 @@ async function putServer(req, res) {
return res.status(204).end()
} catch (err) {
log.error('failed to save a server', { server: id, error: err.message })
return res.status(500).json({ error: 'Failed to save the server' })
return res.status(500).json({ message: 'Failed to save the server' })
}
}
@@ -80,7 +80,7 @@ async function deleteServer(req, res) {
try {
const existing = await db.getServer(id)
if (!existing) return res.status(404).json({ error: 'No such server' })
if (!existing) return res.status(404).json({ message: 'No such server' })
await db.deleteServer(id)
await core.activity.log({ req, action: 'rust.server.delete', detail: { server: id } })
@@ -88,7 +88,7 @@ async function deleteServer(req, res) {
return res.status(204).end()
} catch (err) {
log.error('failed to delete a server', { server: id, error: err.message })
return res.status(500).json({ error: 'Failed to delete the server' })
return res.status(500).json({ message: 'Failed to delete the server' })
}
}
@@ -106,7 +106,7 @@ async function testServer(req, res) {
try {
const row = await db.getServer(id)
if (!row) return res.status(404).json({ error: 'No such server' })
if (!row) return res.status(404).json({ message: 'No such server' })
const result = await sidecar.health(servers.withToken(row))
@@ -125,7 +125,7 @@ async function testServer(req, res) {
})
} catch (err) {
log.error('failed to probe a sidecar', { server: id, error: err.message })
return res.status(500).json({ error: 'Failed to probe the sidecar' })
return res.status(500).json({ message: 'Failed to probe the sidecar' })
}
}

View File

@@ -0,0 +1,71 @@
// ── The `admin.users.detail` slot's handlers ──────────────────────────────
//
// What an operator can see and do about one website user's Rust identity. The
// user id is the PARENT's — `req.params.id` off core's `/admin/users/:id` — and
// every statement here is scoped by it, so a panel opened on one user cannot
// read or write another's rows by editing a path segment.
const core = require('../../core')
const links = require('../../model/links/links.model')
const log = core.logger('admin')
/**
* GET /admin/users/:id/rust/links
*
* The linked Steam accounts and, per server, what this module knows about the
* player behind them — all-time rather than this wipe's, because an operator
* looking at a user wants their history and the public leaderboard already
* answers the other question.
*
* **An empty array is an answer.** Most users have no Rust link at all, and the
* panel renders nothing rather than an error for them.
*/
async function listLinks(req, res) {
try {
res.json({ links: await links.forAdmin(req.params.id) })
} catch (err) {
log.error('failed to read a users Rust links', { error: err.message })
res.status(500).json({ message: 'Failed to read this users Rust accounts' })
}
}
/**
* DELETE /admin/users/:id/rust/links/:steamId — staff sever a link (D25).
*
* **This is the counterweight to D23.** The site refuses to move a Steam id that
* another website account already holds, and the player's own way out is
* `/unlink` in game — which is no way out at all for somebody who has lost access
* to that Steam account, or to the site account holding it. Staff are that route.
*
* Scoped by the parent user id in the statement rather than checked first: the
* ownership test and the deletion are one operation, and a link that belongs to a
* different user answers 404 from the page it was not on.
*/
async function removeLink(req, res) {
const { steamId } = req.params
const userId = req.params.id
try {
const removed = await links.unlinkOwned(steamId, userId)
if (!removed) return res.status(404).json({ message: 'That account is not linked to this user' })
// The one write this panel has, so it is the one thing here worth an audit
// row: after phase 7 a link is what permissions are granted against, and
// "who severed it" stops being a curiosity.
await core.activity.log({
req,
action: 'rust.account.unlink.staff',
detail: { steamId, userId: Number(userId) },
})
return res.json({ unlinked: true })
} catch (err) {
log.error('failed to unlink a Steam account', { error: err.message })
return res.status(500).json({ message: 'Failed to unlink that account' })
}
}
module.exports = { listLinks, removeLink }

View File

@@ -0,0 +1,73 @@
// ── The `admin.users.detail` extension slot ───────────────────────────────
//
// R13's first slot, and the phase criterion in one file: *an operator sees the
// Steam id inside core's own user page*.
//
// MODULE_API.md §2.4's fourth mount shape — module routes hanging off a CORE
// resource. `/admin/users/:id` is a URL core owns and this module has something
// to say about it, so the routes cannot move behind a `/rust` prefix and cannot
// be registered anywhere else either. Core declares the slot; a module fills it,
// and only one module may.
//
// Three things about this router that are not true of the other three:
//
// • **`mergeParams: true`**, because the user id belongs to the parent. Without
// it `req.params.id` is undefined and every statement here silently scopes to
// nothing.
// • **The paths keep the module's own segment** (`/rust/links`, not `/links`).
// Core owns the resource and other modules may fill their own slots on other
// resources; a bare `/links` would be this module claiming a word on a URL it
// does not own.
// • **The gate is stricter than the admin tier's.** Core's users router is
// `requireRole('admin')` and the slot is mounted inside it, so editors and
// moderators never reach here — which is right for a surface that can sever
// what phases 7 and 13 grant against.
//
// The client half is registered under the SAME name (`registry.registerExtension`
// in `entry.jsx`) and builds its own client for these two routes; a slot passes a
// component `userId` and nothing else.
const core = require('../../core')
const express = core.express
const { param } = core.validator
const usersRust = require('./usersRust.controller')
const { validate } = core.middleware
// Same bound the player tier states, for the same reason: nothing but digits
// reaches a `WHERE steam_id = ?`.
const STEAM_ID_RE = /^[0-9]{5,32}$/
const usersRustRouter = express.Router({ mergeParams: true })
usersRustRouter.get(
'/rust/links',
// #swagger.tags = ['Admin · Users']
// #swagger.summary = 'A users linked Steam accounts and their Rust record (admin only)'
// #swagger.description = 'Every Steam account linked to this website user, with the display name the game last saw and, per server, all-time kills / deaths / playtime across every wipe. Fills the admin.users.detail extension slot.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
/* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { $ref: "#/components/schemas/RustAdminLinkList" } } } } */
param('id').isInt(),
validate,
usersRust.listLinks,
)
usersRustRouter.delete(
'/rust/links/:steamId',
// #swagger.tags = ['Admin · Users']
// #swagger.summary = 'Sever a users Steam link (admin only)'
// #swagger.description = 'Staff release a link on this users behalf. It is the counterweight to the site refusing to move a Steam id another account holds: a player who cannot reach that Steam account in game has no other way back. Recorded in the activity log.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
// #swagger.parameters['steamId'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Steam id to release.' }
/* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { type: "object", properties: { unlinked: { type: "boolean", example: true } } } } } } */
/* #swagger.responses[404] = { description: 'Not linked to this user', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
param('steamId').matches(STEAM_ID_RE),
validate,
usersRust.removeLink,
)
module.exports = usersRustRouter

View File

@@ -1,11 +1,27 @@
// ── Player · Rust — the handlers ──────────────────────────────────────────
//
// See the router for why this tier is thin in phase 1. The one thing it must not
// do is reshape the list itself: it calls the same model the public tier does, so
// the two answers cannot drift while they are meant to be the same.
// Two things live here now: the server list as a signed-in caller sees it (phase
// 1's honest placeholder, which must not reshape the list it calls the same
// model the public tier does so the two cannot drift), and R1's identity link.
//
// ── Every refusal is a sentence, and they are not interchangeable ─────────
//
// The link handler's whole job is turning a discriminated result into the right
// thing to tell a player, and the four wrong answers are wrong in different ways:
//
// • "that code is unknown or expired" → run `/link` again
// • "another account holds that Steam id" → run `/unlink` in game, or ask staff
// • "we could not reach a server" → try again in a minute; the code is fine
// • "no servers are configured" → nothing the player can do at all
//
// A player told to run `/link` again when the server their code came from was
// merely unreachable will run it again, get another code from the same
// unreachable server, and be told the same thing. That is the failure the
// `unsure` branch exists to prevent.
const core = require('../../core')
const links = require('../../model/links/links.model')
const servers = require('../../model/servers/servers.model')
const log = core.logger('player')
@@ -15,8 +31,107 @@ async function listServers(req, res) {
res.json({ servers: await servers.listPublic() })
} catch (err) {
log.error('failed to read the server list', { error: err.message })
res.status(500).json({ error: 'Failed to read the server list' })
res.status(500).json({ message: 'Failed to read the server list' })
}
}
module.exports = { listServers }
/** GET /player/rust/links — the Steam accounts the caller holds. */
async function listLinks(req, res) {
try {
res.json({ links: await links.listForUser(req.user.id) })
} catch (err) {
log.error('failed to read a players links', { error: err.message })
res.status(500).json({ message: 'Failed to read your linked accounts' })
}
}
/**
* POST /player/rust/link — redeem a code from `/link` in game.
*
* The fleet loop is the model's (D24); this maps its answer onto a status and a
* sentence. **A refused code is a 400 and an unreachable server is a 503**,
* because a client that cannot tell them apart cannot tell a player whether to
* try again or to go and get a new code.
*/
async function confirmLink(req, res) {
const code = String(req.body.code || '').trim()
try {
const result = await links.redeem({ code, userId: req.user.id })
if (result.ok) {
// Logged on the player tier too, not only for admin writes: this is the
// moment a website account starts being able to hold permissions and
// entitlements in a game, and "when did this account become that Steam id"
// is a question an operator will eventually need answered.
await core.activity.log({
req,
action: 'rust.account.link',
detail: { steamId: result.link.steamId, serverId: result.link.serverId },
})
return res.json({ linked: true, link: result.link, already: Boolean(result.already) })
}
switch (result.reason) {
case 'taken':
// Naming the holder is deliberate and it is not a leak: the player is
// signed in, the account named is one they may well own, and without the
// name the advice ("sign in as that account, or ask staff") is unusable.
return res.status(409).json({
message: result.username
? `That Steam account is already linked to ${result.username}. Run /unlink in game to release it.`
: 'That Steam account is already linked to another website account. Run /unlink in game to release it.',
})
case 'unsure':
return res.status(503).json({
message:
'One of the servers could not be reached, so that code could not be checked. ' +
'Your code is still good — try again in a minute.',
})
case 'offline':
return res.status(503).json({
message: 'The game servers are unreachable right now — try again in a minute.',
})
case 'no-servers':
return res.status(503).json({ message: 'No Rust servers are configured on this site yet.' })
default:
return res.status(400).json({
message: 'That code is unknown or has expired. Type /link in game for a new one.',
})
}
} catch (err) {
log.error('failed to confirm a link code', { error: err.message })
return res.status(500).json({ message: 'Failed to confirm that code' })
}
}
/**
* DELETE /player/rust/links/:steamId — release a link the caller holds.
*
* Scoped to the caller inside the statement, so "not linked" and "not yours"
* answer the same 404 — a signed-in stranger must not be able to discover which
* Steam ids are linked by deleting them one at a time.
*/
async function removeLink(req, res) {
const { steamId } = req.params
try {
const removed = await links.unlinkOwned(steamId, req.user.id)
if (!removed) return res.status(404).json({ message: 'That account is not linked to you' })
await core.activity.log({ req, action: 'rust.account.unlink', detail: { steamId } })
return res.json({ unlinked: true })
} catch (err) {
log.error('failed to unlink', { error: err.message })
return res.status(500).json({ message: 'Failed to unlink that account' })
}
}
module.exports = { listServers, listLinks, confirmLink, removeLink }

View File

@@ -4,38 +4,109 @@
// sits behind `noindex, requireAuth`, so every handler here has a signed-in user
// and none of them re-implements that check.
//
// ── Why this tier exists in phase 1, and what it honestly holds ───────────
// ── Why this tier exists in phase 1, and what it holds now ────────────────
//
// R14 puts this module on all three tiers from the start, and the loader holds
// `module.json`'s `mounts` against what is actually registered in **both**
// directions — a declared prefix that never gets a router fails the load. So the
// declaration and the registration land together or not at all.
//
// What this tier will carry is the signed-in view of a server: the viewer's own
// linked Steam identity, their own presence, their own entitlements. None of that
// exists yet — identity is a later phase — so the one route here answers the
// server list as the signed-in caller sees it, which is currently the same list
// the public tier serves.
// Phase 1 said this tier would carry the signed-in view of a server the
// viewer's own linked Steam identity, their own presence, their own entitlements
// — and that identity was a later phase. This is that phase: `/links`, `/link`
// and `DELETE /links/:steamId` are R1, and everything phases 7 and 13 hand out is
// hung off the row they write.
//
// That is deliberately a real route and not a placeholder: it is the URL the app
// and the SPA will call, and it starts answering correctly now rather than
// changing address later. What it must not become is a second copy of the public
// shape — it delegates to the same model, so the two cannot drift.
// `/servers` stays what it was: the same list the public tier serves, answered on
// the authenticated tier so per-player detail can be added without moving the
// address. It delegates to the same model, so the two cannot drift.
const core = require('../../core')
const express = core.express
const servers = require('./rust.controller')
const { body, param } = core.validator
const rust = require('./rust.controller')
const { validate, rateLimit } = core.middleware
const playerRustRouter = express.Router()
// A Steam id as the game states it — `BasePlayer.UserIDString`, a 17-digit
// SteamID64. Bounded rather than pinned at 17 because the column is a string and
// a test rig's ids are shorter; what matters is that nothing but digits reaches a
// `WHERE steam_id = ?`.
const STEAM_ID_RE = /^[0-9]{5,32}$/
/**
* R1 requires the link code be rate-limited, and this is where that lands.
*
* The code is six characters from a 32-glyph alphabet, so guessing one is a
* 1-in-10⁹ shot — but only while the guesser is made to pay for each attempt.
* Ten per quarter-hour per IP turns that into centuries; without it a script
* could work through the space in an afternoon, and phases 7 and 13 make the
* prize a set of in-game permissions and entitlements rather than a cosmetic
* badge.
*
* Its own limiter rather than core's `accountChangeLimiter`: this is guessing
* somebody else's secret, not changing your own password, and sharing a counter
* would mean one of the two silently sets the policy for the other.
*/
const linkLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
label: 'rust-link-code',
message: 'Too many link attempts. Please try again later.',
})
playerRustRouter.get(
'/servers',
// #swagger.tags = ['Player · Rust']
// #swagger.summary = 'The Rust servers, for a signed-in player'
// #swagger.description = 'The same servers the public list carries, answered on the authenticated tier. It is the address a signed-in client calls, so that per-player detail can be added here without moving it. Requires a session.'
/* #swagger.responses[200] = { description: 'The server list', content: { "application/json": { schema: { $ref: "#/components/schemas/RustServerList" } } } } */
servers.listServers,
rust.listServers,
)
playerRustRouter.get(
'/links',
// #swagger.tags = ['Player · Rust']
// #swagger.summary = 'The Steam accounts the caller has linked'
// #swagger.description = 'Every Steam account linked to the signed-in user, newest first. A link is fleet-wide: it is keyed by Steam id, not by server, because a Steam account is one person across every server an operator runs.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { $ref: "#/components/schemas/RustLinkList" } } } } */
rust.listLinks,
)
playerRustRouter.post(
'/link',
// #swagger.tags = ['Player · Rust']
// #swagger.summary = 'Link a Steam account with a one-time code from /link in game'
// #swagger.description = 'The player types /link in game, the plugin hands them a six-character code privately, and they enter it here within five minutes. The site asks each configured server in turn until one recognises the code. A Steam account already linked to a different website account is refused rather than moved — the way out is /unlink in game.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustLinkRequest" } } } } */
/* #swagger.responses[200] = { description: 'Linked', content: { "application/json": { schema: { $ref: "#/components/schemas/RustLinkResult" } } } } */
/* #swagger.responses[400] = { description: 'Unknown or expired code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'That Steam account is linked to another website account', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[429] = { description: 'Too many link attempts', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[503] = { description: 'A server could not be reached — the code is still good', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
linkLimiter,
body('code').isString().trim().isLength({ min: 4, max: 32 }),
validate,
rust.confirmLink,
)
playerRustRouter.delete(
'/links/:steamId',
// #swagger.tags = ['Player · Rust']
// #swagger.summary = 'Release a Steam account the caller has linked'
// #swagger.description = 'Removes the callers own link. Scoped to the caller in the statement, so a link belonging to somebody else answers the same 404 as one that does not exist.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['steamId'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Steam id to release.' }
/* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { type: "object", properties: { unlinked: { type: "boolean", example: true } } } } } } */
/* #swagger.responses[404] = { description: 'Not linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('steamId').matches(STEAM_ID_RE),
validate,
rust.removeLink,
)
module.exports = playerRustRouter

View File

@@ -21,7 +21,31 @@ async function listServers(req, res) {
res.json({ servers: await servers.listPublic() })
} catch (err) {
log.error('failed to read the server list', { error: err.message })
res.status(500).json({ error: 'Failed to read the server list' })
res.status(500).json({ message: 'Failed to read the server list' })
}
}
/**
* One server, or a 404.
*
* **The 404 is the feature.** Everything else under `/servers/:id` answers an
* empty list for a server that does not exist — an unknown id has no events, no
* leaderboard and nobody online, and each of those is a perfectly good answer to
* the question it was asked. Only this route can tell the page that the server
* itself is not there, which is what stops `/rust/servers/typo` rendering as a
* quiet server with nothing to say.
*/
async function getServer(req, res) {
try {
const server = await servers.getPublic(req.params.id)
if (!server) {
res.status(404).json({ message: 'No such server' })
return
}
res.json({ server })
} catch (err) {
log.error('failed to read a server', { server: req.params.id, error: err.message })
res.status(500).json({ message: 'Failed to read the server' })
}
}
@@ -45,7 +69,7 @@ async function listEvents(req, res) {
})
} catch (err) {
log.error('failed to read events', { server: req.params.id, error: err.message })
res.status(500).json({ error: 'Failed to read events' })
res.status(500).json({ message: 'Failed to read events' })
}
}
@@ -61,7 +85,7 @@ async function listLeaderboard(req, res) {
})
} catch (err) {
log.error('failed to read the leaderboard', { server: req.params.id, error: err.message })
res.status(500).json({ error: 'Failed to read the leaderboard' })
res.status(500).json({ message: 'Failed to read the leaderboard' })
}
}
@@ -70,7 +94,7 @@ async function listWipes(req, res) {
res.json({ wipes: await events.wipes(req.params.id) })
} catch (err) {
log.error('failed to read wipes', { server: req.params.id, error: err.message })
res.status(500).json({ error: 'Failed to read wipes' })
res.status(500).json({ message: 'Failed to read wipes' })
}
}
@@ -79,8 +103,8 @@ async function listOnline(req, res) {
res.json({ players: await events.online(req.params.id) })
} catch (err) {
log.error('failed to read presence', { server: req.params.id, error: err.message })
res.status(500).json({ error: 'Failed to read who is online' })
res.status(500).json({ message: 'Failed to read who is online' })
}
}
module.exports = { listServers, listEvents, listLeaderboard, listWipes, listOnline }
module.exports = { listServers, getServer, listEvents, listLeaderboard, listWipes, listOnline }

View File

@@ -52,6 +52,18 @@ rustRouter.get(
// Protocol 2 carries IP addresses and player reports; they are stored, and they
// do not come out here.
rustRouter.get(
'/servers/:id',
// #swagger.tags = ['Public · Rust']
// #swagger.summary = 'One Rust server'
// #swagger.description = 'The same shape the list answers with, for one server, and a `404` when there is no such server or an operator has disabled it. The detail page needs the difference: every other route under this path answers an empty list for an id that does not exist, because an unknown server genuinely has no events and nobody online.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The servers slug', schema: { type: 'string' } }
/* #swagger.responses[200] = { description: 'The server' } */
/* #swagger.responses[404] = { description: 'No such server, or it is disabled' } */
siteMode,
servers.getServer,
)
rustRouter.get(
'/servers/:id/events',
// #swagger.tags = ['Public · Rust']

View File

@@ -60,6 +60,22 @@ const TIER_BASE = {
player: '/api/v1/player',
}
// MODULE_API.md §2.4's slot table, and the FOURTH base this generator needs.
//
// Phase 6 found the hole: a slot router is not registered under a tier, so the
// loop below could not see it and the two routes it serves were generated by
// nothing — a fragment that was internally consistent and silently described two
// routes fewer than the module serves. The frozen-manifest check would have
// caught it (every route must have an operation), which is precisely why that
// check exists; this is the fix it points at.
//
// A slot's mount is CORE's, not ours, so it cannot be derived from anything in
// this repo. That makes it the same kind of constant as `TIER_BASE` above, and it
// is held to account the same way: by a real core in the frozen-manifest job.
const SLOT_MOUNT = {
'admin.users.detail': '/api/v1/admin/users/:id',
}
/**
* Run `register()` with a recording api and return `[{ file, prefix, what }]`.
*
@@ -88,6 +104,15 @@ function mountedRouters() {
}
}
// A filled slot is a mount too. Registered through a different call, mounted
// on a resource core owns, and — unlike a tier router — carrying the parent's
// `:id` in its own base path.
for (const { slot, router } of api.record.extensions || []) {
const mount = SLOT_MOUNT[slot]
if (!mount) throw new Error(`swagger: filled slot "${slot}", which §2.4's table does not list`)
mounts.push({ router, prefix: mount, what: `slot ${slot}` })
}
return mounts.map(({ router, prefix, what }) => {
const file = fileOf(router)
if (!file) {

View File

@@ -52,18 +52,19 @@ const TIMEOUT_MS = 12000
* here, `PROTOCOL_VERSION` in the sidecar, `ProtocolVersion` in the bridge
* plugin, and `protocol` in its `overlay.toml`.
*
* **2the read path.** The bump lands here in the same change as the emitters,
* even though this module does not yet consume any of the new frames: the
* sidecar refuses a client declaring a different version with a `409`, so a
* module left on 1 would stop being able to read the server board it has been
* reading all along. A constant that lags the deployment is not a safe default;
* it is an outage with a version number on it.
* **3identity.** Protocol 2 was the read path; 3 adds the first message the
* WEBSITE originates (`link.confirm`) and the two account frames the plugin
* emits beside it. The bump lands here in the same change as the emitters,
* because the sidecar refuses a client declaring a different version with a
* `409`: a module left on 2 would stop being able to read the server board it
* has been reading all along. A constant that lags the deployment is not a safe
* default; it is an outage with a version number on it.
*
* It is sent on every request as `X-RustLink-Version`, which turns a mismatched
* deployment into a `409` naming both numbers instead of a parse failure three
* layers further in.
*/
const PROTOCOL_VERSION = 2
const PROTOCOL_VERSION = 3
/** What a caller gets back. Shaped once so every call site reads the same. */
function reply(ok, status, data = null) {
@@ -189,6 +190,26 @@ const feed = (server, since, limit = 200) =>
/** Where the sidecar's history currently ends. What a new server's cursor starts at. */
const feedTail = (server) => request(server, '/feed')
/**
* Redeem a one-time link code against one server (protocol 3).
*
* **The only call in this file that is not a GET**, and the only one that asks
* the game a question rather than reading what it already said. The sidecar
* forwards the code to the plugin, which holds the pending codes in memory, and
* hands back what it answers.
*
* **A refused code comes back `{ ok: true }`.** `link.ok` and `link.error` are
* both answers — the sidecar reserves its own failures for the transport (503
* when the game is down, 504 when it is up and silent) — and the caller has to
* tell "that code is wrong" from "the game never replied" to say the right thing
* to a player. So the discrimination happens on `data.kind`, not on `ok`.
*
* A code is spent on the plugin's FIRST lookup whether or not it turns out to be
* expired, so this must never be called speculatively for its answer alone.
*/
const confirmLink = (server, code) =>
request(server, '/link/confirm', { method: 'POST', body: { code } })
module.exports = {
TIMEOUT_MS,
PROTOCOL_VERSION,
@@ -199,5 +220,6 @@ module.exports = {
boards,
feed,
feedTail,
confirmLink,
joinUrl,
}

View File

@@ -100,6 +100,101 @@ module.exports = {
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' } },
},
},
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 },
},
},
},
},
},
},
},
},
RustSidecarProbe: {
type: 'object',
description: 'What a sidecar said when probed (POST /admin/rust/servers/{id}/test).',

View File

@@ -28,7 +28,7 @@ test('an unknown kind is not public — the default is deny', () => {
assert.equal(catalogue.isPublic('player.location'), false)
})
test('nothing carrying an IP address or a report is public', () => {
test('nothing carrying an IP address, a report or an identity is public', () => {
for (const kind of [
'player.login.attempt',
'player.approved',
@@ -36,6 +36,11 @@ test('nothing carrying an IP address or a report is public', () => {
'player.unbanned',
'player.reported',
'entity.destroyed',
// Protocol 3. A link request on a public killfeed would tell everyone which
// Steam id is about to become a named website account, and an unlink would
// say when somebody stopped being one.
'account.link.requested',
'account.unlinked',
]) {
assert.equal(catalogue.isPublic(kind), false, `${kind} must not be public`)
assert.ok(catalogue.STAFF_KINDS.includes(kind), `${kind} must be classified, not merely absent`)
@@ -82,13 +87,13 @@ test('every kind is classified exactly once', () => {
assert.equal(seen.size, catalogue.PUBLIC_KINDS.length + catalogue.STAFF_KINDS.length)
})
test('the classification covers exactly the kinds protocol 2 defines', () => {
test('the classification covers exactly the kinds protocol 3 defines', () => {
// The spec lives in another repository, so the list is restated here rather
// than parsed — and restating it is the point: adding a kind to the protocol
// without deciding who may see it has to fail somewhere, and this is where.
//
// Sourced from docs/rust-link/PROTOCOL.md §8.4.
const PROTOCOL_2 = [
const PROTOCOL_3 = [
'player.connected',
'player.disconnected',
'player.respawned',
@@ -104,7 +109,9 @@ test('the classification covers exactly the kinds protocol 2 defines', () => {
'server.wipe',
'server.initialized',
'server.shutdown',
'account.link.requested',
'account.unlinked',
]
assert.deepEqual([...catalogue.ALL_KINDS].sort(), [...PROTOCOL_2].sort())
assert.deepEqual([...catalogue.ALL_KINDS].sort(), [...PROTOCOL_3].sort())
})

View File

@@ -148,3 +148,20 @@ test('the modules protocol version agrees with the manifest it ships beside',
assert.strictEqual(typeof sidecar.PROTOCOL_VERSION, 'number')
assert.ok(sidecar.PROTOCOL_VERSION >= 1)
})
test('an identity capability is declared, and it is the module id (phase 5, D16)', () => {
// Core flattens every started module's capabilities into ONE list, so a client
// asking "is this module installed" needs a string only this module can
// declare. `servers` is not that string — it names a surface, and another
// module could name it too — which is the whole reason this one exists beside
// the five surface words.
//
// It is asserted against `manifest.id` rather than against the literal "rust"
// so that the two cannot drift: the day the id changes, the capability a
// client gates a whole navigation group on has to change with it.
assert.ok(
manifest.capabilities.includes(manifest.id),
`module.json must declare "${manifest.id}" as a capability — it is the only string a client can` +
' use to tell this module apart from any other, and the Android app gates its Rust rows on it',
)
})

View File

@@ -0,0 +1,134 @@
// ── The field an error has to be in ───────────────────────────────────────
//
// **The walk found this, and no test could have.** Core's request primitive is
// the only thing that reads a module's failures:
//
// const message = (data && data.message) || res.statusText || 'Request failed'
//
// So a body shaped `{ error: '…' }` is not rendered as a worse message — it is
// not rendered at all. The player sees `Service Unavailable`, which is what the
// link page showed for every one of the four sentences this phase exists to
// write, until a browser said so.
//
// This module answered `{ error }` from its first phase and got away with it,
// because until now every failure landed in `ErrorState` on a page whose whole
// content was missing — where a generic sentence is honest. A form is different:
// the sentence IS the outcome, and the four are not interchangeable.
//
// The rule is core's `Error` schema (`{ message }`), which every one of this
// module's `#swagger.responses` already pointed at. So this suite is the schema
// those annotations claim, asserted against what the handlers actually send.
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx } = require('./_fakes')
function withCore() {
require('../core')._reset()
require('../core').init(fakeCtx())
}
/** A response double that records the status and the body. */
function fakeRes() {
const res = {
statusCode: 200,
body: null,
status(code) {
res.statusCode = code
return res
},
json(body) {
res.body = body
return res
},
}
return res
}
/** Every outcome `redeem` can answer, and the status each has to become. */
const OUTCOMES = [
[{ ok: false, reason: 'taken', username: 'someone-else' }, 409, /already linked to someone-else/],
[{ ok: false, reason: 'unsure' }, 503, /still good/],
[{ ok: false, reason: 'offline' }, 503, /unreachable/],
[{ ok: false, reason: 'no-servers' }, 503, /No Rust servers/],
[{ ok: false, reason: 'rejected' }, 400, /unknown or has expired/],
]
test('every refusal reaches the player as a sentence, in the field core reads', async () => {
for (const [outcome, status, matches] of OUTCOMES) {
withCore()
const links = require('../model/links/links.model')
const controller = require('../router/player/rust.controller')
links.redeem = async () => outcome
const res = fakeRes()
await controller.confirmLink({ body: { code: 'K7M2PQ' }, user: { id: 4 } }, res)
assert.equal(res.statusCode, status, `${outcome.reason} must be ${status}`)
assert.equal(typeof res.body.message, 'string', `${outcome.reason} sent no \`message\``)
assert.match(res.body.message, matches)
// The half that is easy to leave behind while fixing this: a body carrying
// BOTH fields reads correctly in a browser and keeps the wrong shape alive
// for the next route that copies it.
assert.equal(res.body.error, undefined, `${outcome.reason} still carries an \`error\` field`)
}
})
test('the five outcomes are five different statuses-and-sentences, not one', async () => {
const seen = new Set()
for (const [outcome] of OUTCOMES) {
withCore()
const links = require('../model/links/links.model')
const controller = require('../router/player/rust.controller')
links.redeem = async () => outcome
const res = fakeRes()
await controller.confirmLink({ body: { code: 'K7M2PQ' }, user: { id: 4 } }, res)
seen.add(res.body.message)
}
// "That code is wrong" and "we could not reach the server that has it" send a
// player to do different things, and one of the two is a dead end when it is
// wrong — they run /link again on the server that is down and get the same
// answer for as long as it stays down.
assert.equal(seen.size, OUTCOMES.length, 'two outcomes tell the player the same thing')
})
test('no handler in this module answers in a field core cannot read', async () => {
// The other controllers, the same way — driven rather than grepped, because the
// shape that matters is what a handler SENDS. Each is given a model that throws,
// which is every controller's own 500 path and the one branch they all have.
withCore()
const cases = [
['public', '../router/public/rust.controller', 'listServers', { params: {}, query: {} }],
['player', '../router/player/rust.controller', 'listServers', { params: {}, query: {}, user: { id: 4 } }],
['player', '../router/player/rust.controller', 'listLinks', { params: {}, user: { id: 4 } }],
['admin', '../router/admin/rust.controller', 'listServers', { params: {}, query: {} }],
['slot', '../router/admin/usersRust.controller', 'listLinks', { params: { id: '4' } }],
]
for (const [tier, modulePath, handler, req] of cases) {
withCore()
// Core's `query` is the fake's spy; make it throw so every handler takes its
// failure branch.
require('../core')._reset()
require('../core').init(fakeCtx({
db: { query: () => Promise.reject(new Error('the database is not there')), pool: {} },
}))
const controller = require(modulePath)
const res = fakeRes()
await controller[handler](req, res)
assert.equal(res.statusCode, 500, `${tier}.${handler} did not fail`)
assert.equal(typeof res.body.message, 'string', `${tier}.${handler} sent no \`message\``)
assert.equal(res.body.error, undefined, `${tier}.${handler} answers in \`error\``)
}
})

View File

@@ -157,21 +157,50 @@ test('every path in the fragment is fully qualified', () => {
test("the manifest and the module's declared mounts agree", () => {
const { routes } = JSON.parse(fs.readFileSync(MANIFEST, 'utf8'))
const { mounts } = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'module.json'), 'utf8'))
const manifest = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'module.json'), 'utf8'))
const declared = []
for (const [tier, prefixes] of Object.entries(mounts)) {
for (const [tier, prefixes] of Object.entries(manifest.mounts)) {
for (const prefix of prefixes) declared.push(`/api/v1/${tier}${prefix}/`)
}
// Every route this module serves is under a prefix it declared. There is no
// exception here yet, and that is the point of asserting it now: phase 6 adds
// the `admin.users.detail` extension slot, whose routes live under core's
// `/api/v1/admin/users/` rather than under any mount of ours (§2.4). When that
// arrives this test must grow the exception deliberately, rather than a route
// outside every declared mount arriving unnoticed.
// **The exception this test predicted, now grown deliberately.** Phase 6 fills
// `admin.users.detail`, whose routes live on a resource CORE owns
// (`/api/v1/admin/users/:id`) rather than under any mount of ours — §2.4's
// fourth mount shape. So a route is legitimate if it is under a declared
// prefix, or under the mount of a slot this module declares.
//
// The slot's mount is restated here rather than imported, for the same reason
// the protocol catalogue is restated in `catalogue.test.js`: it is CORE's
// constant, and a module that derived it from its own generator would be
// checking that file against itself.
const SLOT_MOUNT = { 'admin.users.detail': '/api/v1/admin/users/' }
const slots = (manifest.extensions || []).map((slot) => {
const mount = SLOT_MOUNT[slot]
assert.ok(mount, `module.json declares slot "${slot}", which §2.4's table does not list`)
return { slot, mount }
})
const used = new Set()
for (const route of routes) {
const under = declared.some((d) => route.path.startsWith(d))
assert.ok(under, `${route.method} ${route.path} is served from outside every mount module.json declares`)
if (declared.some((d) => route.path.startsWith(d))) continue
const slot = slots.find((s) => route.path.startsWith(s.mount))
assert.ok(
slot,
`${route.method} ${route.path} is served from outside every mount module.json declares, ` +
'and outside every slot it fills',
)
used.add(slot.slot)
}
// The other half, and the reason the exception is narrow: a declared slot that
// contributes no route is an exception widening this check for nothing. Core
// never checks that a declared slot was filled (`checkDeclared` covers `mounts`
// alone), so this is the only place it is noticed.
for (const { slot } of slots) {
assert.ok(used.has(slot), `module.json declares "${slot}" but no route in the manifest comes from it`)
}
})

View File

@@ -0,0 +1,82 @@
// ── The shape of the identity surface ─────────────────────────────────────
//
// Three properties that are invisible in review and expensive in production:
//
// • **the link route is rate-limited** (R1). Six characters from a 32-glyph
// alphabet is a good code only while a guesser is made to pay per attempt,
// and once phase 7 grants permissions against a link, guessing one is a
// privilege-escalation path rather than a nuisance.
// • **the extension router merges its parent's params**. Without
// `mergeParams`, `req.params.id` is `undefined` and every statement in that
// panel silently scopes to no user — a panel that reads as "this user has no
// Rust account" for everybody.
// • **the extension's paths keep the module's own segment.** Core owns
// `/admin/users/:id`; a bare `/links` would be this module claiming a word on
// a URL it does not own, and the next module to fill a slot would collide.
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx, fakeApi } = require('./_fakes')
function register(ctx = fakeCtx()) {
require('../core')._reset()
const api = fakeApi()
require('../index')(ctx, api)
return api
}
/** `[{ method, path, handlers }]` for one express router. */
function routesOf(router) {
return router.stack
.filter((layer) => layer.route)
.map((layer) => ({
path: layer.route.path,
method: Object.keys(layer.route.methods)[0].toUpperCase(),
handlers: layer.route.stack.map((s) => s.handle),
}))
}
test('the player tier serves the three identity routes, and nothing else new', () => {
const api = register()
const routes = routesOf(api.record.routes.player['/rust'])
assert.deepEqual(
routes.map((r) => `${r.method} ${r.path}`).sort(),
['DELETE /links/:steamId', 'GET /links', 'GET /servers', 'POST /link'],
)
})
test('redeeming a code is rate-limited, and by a limiter of its own', () => {
const api = register()
const post = routesOf(api.record.routes.player['/rust']).find((r) => r.method === 'POST')
// The fake's `rateLimit` hands back a pass-through carrying the options it was
// given, so the policy itself is assertable — a limiter that was quietly
// removed, or one built with core's `accountChangeLimiter` shared counter,
// both fail here.
const limiter = post.handlers.find((h) => h.options && h.options.label === 'rust-link-code')
assert.ok(limiter, 'POST /link must carry its own rate limiter (R1)')
assert.equal(limiter.options.max, 10)
assert.equal(limiter.options.windowMs, 15 * 60 * 1000)
// First in the chain: a limiter behind the validator would let an attacker
// spend the cheap half of the request unbounded.
assert.equal(post.handlers[0], limiter)
})
test('the admin.users.detail router merges the parents params and keeps its own segment', () => {
const api = register()
const slot = api.record.extensions.find((e) => e.slot === 'admin.users.detail')
assert.ok(slot, 'the server half of admin.users.detail must be registered')
assert.equal(slot.router.mergeParams, true)
const paths = routesOf(slot.router).map((r) => `${r.method} ${r.path}`).sort()
assert.deepEqual(paths, ['DELETE /rust/links/:steamId', 'GET /rust/links'])
for (const route of routesOf(slot.router)) {
assert.ok(route.path.startsWith('/rust/'), `${route.path} must live under this module's own segment`)
}
})

View File

@@ -327,3 +327,35 @@ test('a board replaces presence rather than appending to it', async () => {
assert.match(presence[0].sql, /^DELETE FROM rust_presence/)
assert.match(presence[1].sql, /INSERT INTO rust_presence/)
})
// ── Protocol 3: the frame that changes something other than a counter ─────
test('an in-game /unlink severs the site link, scoped by Steam id alone', async () => {
const rec = withRecorder()
const ingest = require('../ingest')
await ingest.apply('main', item('account.unlinked', { steamId: '7656', name: 'Wanderer', origin: 'in-game' }))
const del = rec.statements.find((st) => st.sql.trim().toUpperCase().startsWith('DELETE'))
// It arrives on the FEED rather than through a route because the plugin has no
// link to delete — the site is the author of record. And it is the only way out
// of a link on the wrong account, because the site refuses to move a Steam id
// another account already holds (D23).
assert.ok(del, 'an unlink frame must delete the link')
assert.ok(del.sql.includes('rust_account_links'))
assert.deepEqual(del.params, ['7656'])
})
test('asking for a code links nothing — the code does not travel on the wire', async () => {
const rec = withRecorder()
const ingest = require('../ingest')
await ingest.apply('main', item('account.link.requested', { steamId: '7656', name: 'Wanderer', ttlSec: 300 }))
// The frame exists so an operator can see linking being used. Nothing about it
// is redeemable: the code travels through the player, which is what makes
// typing it proof that they are the one who asked.
assert.equal(rec.touching('rust_account_links').length, 0)
assert.equal(rec.touching('rust_players').length, 1)
})

294
server/test/links.test.js Normal file
View File

@@ -0,0 +1,294 @@
// ── Identity: the fleet loop and the refusal ──────────────────────────────
//
// Two things in this file are worth more than the rest, and both are about
// telling answers apart that a naive implementation collapses:
//
// • **A code is minted by ONE server** and the player types six characters into
// a browser. Every server is asked in turn (D24), and "every reachable server
// said no" is NOT the same answer as "a server could not be reached" — the
// second is the case where the player's code is perfectly good and the advice
// "run /link again" is useless, because it sends them back to the server that
// is down.
//
// • **A Steam id another account holds is refused, never moved** (D23). Once
// phase 7 grants permissions against a link and phase 13 hangs entitlements
// off it, a silent move is an account takeover performed by typing six
// characters.
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx } = require('./_fakes')
/**
* Installs a ctx whose `db.query` answers from a small script.
*
* `rows` is consulted by the first word of the statement, which is as much SQL as
* these tests should know: the point of each one is the decision the model makes,
* not the shape of a SELECT it delegates.
*/
function withCore({ select = [], onInsert = null } = {}) {
const queries = []
const ctx = fakeCtx({
db: {
query: (sql, params) => {
queries.push({ sql, params })
const verb = sql.trim().split(/\s+/)[0].toUpperCase()
if (verb === 'SELECT') {
const next = Array.isArray(select) ? select.shift() : select
return Promise.resolve(next || [])
}
if (verb === 'INSERT' && onInsert) return onInsert(params)
return Promise.resolve({ affectedRows: 1 })
},
pool: {},
},
})
require('../core')._reset()
require('../core').init(ctx)
return { ctx, queries }
}
/** A fleet of `n` servers, and a sidecar that answers from a script. */
function fleetOf(replies) {
const servers = require('../model/servers/servers.model')
const sidecar = require('../sidecarClient')
const asked = []
const ids = Object.keys(replies)
servers.listForPolling = async () => ids.map((id) => ({ id, baseUrl: `http://${id}`, token: 't' }))
sidecar.confirmLink = async (server, code) => {
asked.push({ server: server.id, code })
return replies[server.id]
}
return asked
}
/** The two replies a reachable sidecar can carry, and the one it cannot. */
const linkOk = (steamId, name) => ({ ok: true, status: 'ok', data: { kind: 'link.ok', steamId, name } })
const linkRefused = { ok: true, status: 'ok', data: { kind: 'link.error', reason: 'unknown' } }
const unreachable = { ok: false, status: 'transport-error', data: null }
test('every server is asked until one recognises the code, and the one that answered is recorded', async () => {
const { queries } = withCore({ select: [[], [{ steamId: '7656', userId: 4, name: 'Wanderer', serverId: 'b' }]] })
const links = require('../model/links/links.model')
const asked = fleetOf({ a: linkRefused, b: linkOk('7656', 'Wanderer') })
const result = await links.redeem({ code: 'K7M2PQ', userId: 4 })
assert.equal(result.ok, true)
assert.equal(result.link.steamId, '7656')
// Both servers were asked, in order, with the same code — and the loop stopped
// at the one that said yes.
assert.deepEqual(asked, [{ server: 'a', code: 'K7M2PQ' }, { server: 'b', code: 'K7M2PQ' }])
// The server that minted it is stored. It is not part of the identity — a link
// is fleet-wide — but it is where a support conversation starts.
const insert = queries.find((q) => q.sql.trim().toUpperCase().startsWith('INSERT'))
assert.deepEqual(insert.params, ['7656', 4, 'Wanderer', 'b'])
})
test('a server after the one that answered is never asked', async () => {
withCore({ select: [[], [{ steamId: '7656', userId: 4 }]] })
const links = require('../model/links/links.model')
const asked = fleetOf({ a: linkOk('7656', 'Wanderer'), b: linkRefused, c: linkRefused })
await links.redeem({ code: 'K7M2PQ', userId: 4 })
// A code is spent on the plugin's FIRST lookup, so carrying on after a yes
// would be asking four other game hosts to look up a secret that has already
// been redeemed.
assert.deepEqual(asked.map((a) => a.server), ['a'])
})
test('a Steam id another account holds is refused, not moved — and the loop stops', async () => {
// The whole of D23 in one assertion. The holder is named because the player is
// signed in and the advice ("sign in as that account, or run /unlink") is
// unusable without it.
withCore({ select: [[{ steamId: '7656', userId: 9, username: 'someone-else' }]] })
const links = require('../model/links/links.model')
const asked = fleetOf({ a: linkOk('7656', 'Wanderer'), b: linkRefused })
const result = await links.redeem({ code: 'K7M2PQ', userId: 4 })
assert.equal(result.ok, false)
assert.equal(result.reason, 'taken')
assert.equal(result.username, 'someone-else')
// Asking the rest of the fleet would answer the same question more slowly: the
// verdict is about the Steam id, not about this server.
assert.deepEqual(asked.map((a) => a.server), ['a'])
})
test('a code already redeemed by the SAME user is a success, not an error', async () => {
withCore({ select: [[{ steamId: '7656', userId: 4, name: 'Wanderer', serverId: 'a' }]] })
const links = require('../model/links/links.model')
fleetOf({ a: linkOk('7656', 'Wanderer') })
const result = await links.redeem({ code: 'K7M2PQ', userId: 4 })
// A player who pressed the button twice, or whose confirmation was applied on a
// request that then timed out. Reporting that as a failure would send them to
// run `/link` again for a link they already have.
assert.equal(result.ok, true)
assert.equal(result.already, true)
})
test('"every reachable server refused" is not the same answer as "a server was unreachable"', async () => {
withCore()
const links = require('../model/links/links.model')
fleetOf({ a: linkRefused, b: unreachable })
const result = await links.redeem({ code: 'K7M2PQ', userId: 4 })
// The failure this prevents: a player linked on the server that is down, is
// told their code is wrong, runs `/link` again on that same server, and is told
// the same thing for as long as it stays down.
assert.equal(result.reason, 'unsure')
})
test('a fleet nobody can reach is offline, and a fleet that all refused is a bad code', async () => {
withCore()
let links = require('../model/links/links.model')
fleetOf({ a: unreachable, b: unreachable })
assert.equal((await links.redeem({ code: 'K7M2PQ', userId: 4 })).reason, 'offline')
withCore()
links = require('../model/links/links.model')
fleetOf({ a: linkRefused, b: linkRefused })
assert.equal((await links.redeem({ code: 'K7M2PQ', userId: 4 })).reason, 'rejected')
})
test('a site with no servers configured says so rather than that the code is wrong', async () => {
withCore()
const links = require('../model/links/links.model')
fleetOf({})
assert.equal((await links.redeem({ code: 'K7M2PQ', userId: 4 })).reason, 'no-servers')
})
test('two confirmations of one Steam id race into the primary key, not into a 500', async () => {
// The window the PRIMARY KEY exists for: both requests read "not linked", both
// write. The second insert is refused by the key, and the refusal has to become
// the same sentence the check above produces — otherwise one of two players
// pressing a button at the same moment gets an internal error.
const dup = Object.assign(new Error('duplicate'), { code: 'ER_DUP_ENTRY' })
withCore({
select: [[], [{ steamId: '7656', userId: 9, username: 'someone-else' }]],
onInsert: () => Promise.reject(dup),
})
const links = require('../model/links/links.model')
fleetOf({ a: linkOk('7656', 'Wanderer') })
const result = await links.redeem({ code: 'K7M2PQ', userId: 4 })
assert.equal(result.ok, false)
assert.equal(result.reason, 'taken')
assert.equal(result.username, 'someone-else')
})
test('the same race, won by the caller, is a success', async () => {
const dup = Object.assign(new Error('duplicate'), { errno: 1062 })
withCore({
select: [[], [{ steamId: '7656', userId: 4, name: 'Wanderer', serverId: 'a' }]],
onInsert: () => Promise.reject(dup),
})
const links = require('../model/links/links.model')
fleetOf({ a: linkOk('7656', 'Wanderer') })
const result = await links.redeem({ code: 'K7M2PQ', userId: 4 })
assert.equal(result.ok, true)
assert.equal(result.already, true)
})
test('a link is never shaped with anything a code could be recovered from', async () => {
withCore()
const links = require('../model/links/links.model')
const shaped = links.shape({
steamId: '7656',
userId: 4,
username: 'someone',
name: 'Wanderer',
serverId: 'a',
linkedAt: '2026-09-21T00:00:00Z',
})
// `userId` and `username` are deliberately absent: the caller is the user, and
// a list that carried somebody's website username would be a different fact
// from "you hold this Steam id".
assert.deepEqual(Object.keys(shaped).sort(), ['linkedAt', 'name', 'serverId', 'steamId'])
})
test('an unlink is scoped by user in the statement, not checked before it', async () => {
const { queries } = withCore()
const links = require('../model/links/links.model')
await links.unlinkOwned('7656', 4)
const del = queries.find((q) => q.sql.trim().toUpperCase().startsWith('DELETE'))
// Read-then-write would leave a gap between the ownership test and the
// deletion; one statement closes it, and the row count is what tells "removed"
// from "was not yours".
assert.ok(del.sql.includes('user_id = ?'))
assert.deepEqual(del.params, ['7656', 4])
})
test('the in-game unlink is scoped by Steam id alone, because that is the authority', async () => {
const { queries } = withCore()
const links = require('../model/links/links.model')
await links.unlinkFromGame('7656')
const del = queries.find((q) => q.sql.trim().toUpperCase().startsWith('DELETE'))
// Whoever is connected to the game as that Steam account is who it is — a
// stronger proof of ownership than the site can obtain any other way. Scoping
// this by website user would make `/unlink` fail for the one player who needs
// it: the one who linked the wrong account.
assert.ok(!del.sql.includes('user_id'))
assert.deepEqual(del.params, ['7656'])
})
test('a player sees the name the GAME last saw, not the one they linked under', async () => {
withCore({ select: [[{ steamId: '7656', userId: 4, name: 'Wanderer-old', playerName: 'Wanderer', serverId: 'a', linkedAt: 'x' }]] })
const links = require('../model/links/links.model')
const [link] = await links.listForUser(4)
// Found in a browser: staff saw `Wanderer` on the admin panel and the player
// saw `Wanderer-old` on their own page — the same person, labelled two ways on
// one site, because a Rust name changes on a whim and only one of the two reads
// was joining `rust_players`.
assert.equal(link.name, 'Wanderer')
// And the fallback still holds for a link whose account has never played.
withCore({ select: [[{ steamId: '7656', userId: 4, name: 'Wanderer-old', playerName: null, linkedAt: 'x' }]] })
const again = require('../model/links/links.model')
assert.equal((await again.listForUser(4))[0].name, 'Wanderer-old')
})

116
server/test/refresh.test.js Normal file
View File

@@ -0,0 +1,116 @@
// ── What a refresh writes when nobody answers ─────────────────────────────
//
// The refresh loop has three outcomes (see `boot.js`), and the two unhappy ones
// are the interesting half of this module's promise: the site renders the last
// thing each server said **while every server is off**. A page can only do that
// if the row still holds what the server said.
//
// The defect this suite exists for shipped in phase 3 and was found by walking
// phase 4's own pages: an unreachable refresh called `putState` with two fields,
// and `putState` replaces the row — so the first time a game host rebooted, the
// hostname, the map, the size, the seed and the wipe id were all set to NULL.
// The list then read "Offline" with nothing beside it, which is not "here is
// what we know about a server that is down", it is "we have never heard of it".
//
// It is invisible to any test that stubs a sidecar which answers, which is why
// there was not one.
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx } = require('./_fakes')
function withCore(ctx = fakeCtx()) {
require('../core')._reset()
require('../core').init(ctx)
return ctx
}
/** The columns a description lives in — the ones an unreachable write must not touch. */
const DESCRIPTION = ['hostname', 'level', 'seed', 'world_size', 'boot_id', 'save_created_at', 'wipe_id']
test('an unreachable refresh does not write the description columns at all', async () => {
const queries = []
withCore(fakeCtx({
db: {
query: (sql, params) => {
queries.push({ sql, params })
return Promise.resolve([])
},
pool: {},
},
}))
const db = require('../model/servers/servers.db')
await db.markUnreachable('main', false)
assert.equal(queries.length, 1)
const { sql, params } = queries[0]
// Asserted against the SQL rather than against a round trip, because the whole
// failure is about which columns a statement mentions. A column named here is
// a column that can be nulled.
for (const column of DESCRIPTION) {
assert.ok(!sql.includes(column), `markUnreachable writes ${column}, which is the server's description`)
}
assert.ok(sql.includes('reachable'))
assert.ok(sql.includes('online'))
assert.ok(sql.includes('updated_at'))
assert.deepStrictEqual(params, ['main', 0])
})
test('a sidecar that is up with no game behind it is reachable and offline', async () => {
// The middle outcome, and the one that is easy to collapse into the other two:
// a fresh install whose plugin is not loaded yet. Reporting it as unreachable
// sends an operator to look at the network instead of at the game server.
const queries = []
withCore(fakeCtx({
db: {
query: (sql, params) => {
queries.push({ sql, params })
return Promise.resolve([])
},
pool: {},
},
}))
await require('../model/servers/servers.db').markUnreachable('main', true)
assert.deepStrictEqual(queries[0].params, ['main', 1])
})
test('neither unhappy path calls putState', async () => {
// The regression in one assertion: `putState` is the whole-row write, and
// calling it with two fields is what blanked the description.
withCore(fakeCtx({ db: { query: () => Promise.resolve([]), pool: {} } }))
const db = require('../model/servers/servers.db')
const sidecar = require('../sidecarClient')
const boot = require('../boot')
const originalPut = db.putState
const originalMark = db.markUnreachable
const originalBoards = sidecar.boards
const marked = []
let putCalls = 0
db.putState = async () => { putCalls += 1 }
db.markUnreachable = async (id, reachable) => { marked.push([id, reachable]) }
try {
// Nothing answered.
sidecar.boards = async () => ({ ok: false, status: 0, data: null })
await boot.refreshOne({ id: 'main', baseUrl: 'http://127.0.0.1:1', token: 't', protocol: 2 })
// The sidecar answered, and has never heard from a game.
sidecar.boards = async () => ({ ok: true, status: 200, data: { boards: {} } })
await boot.refreshOne({ id: 'main', baseUrl: 'http://127.0.0.1:1', token: 't', protocol: 2 })
assert.equal(putCalls, 0, 'an unhappy refresh replaced the whole state row')
assert.deepStrictEqual(marked, [['main', false], ['main', true]])
} finally {
db.putState = originalPut
db.markUnreachable = originalMark
sidecar.boards = originalBoards
}
})

View File

@@ -97,10 +97,96 @@ test('the public shape carries nothing about the sidecar', () => {
// someone who did not read this file, and an allowlist is the only assertion
// that catches one.
assert.deepStrictEqual(Object.keys(shaped).sort(), [
'hostname', 'id', 'level', 'maxPlayers', 'name', 'online', 'players', 'seed', 'stale', 'updatedAt', 'worldSize',
'hostname', 'id', 'lastSeenAt', 'level', 'maxPlayers', 'name', 'online', 'players', 'seed', 'stale',
'updatedAt', 'wipeId', 'wipedAt', 'worldSize',
])
})
test('"last reported" is when a frame arrived, not when we last polled', () => {
withCore()
const servers = require('../model/servers/servers.model')
// The defect the phase-4 page walk found, in one assertion. A refresh that
// cannot reach a sidecar still writes `updated_at` — it has to, because that is
// what staleness is computed from — and a page reading it as "last reported"
// told a reader that a server which had been down for days had reported just
// now, every thirty seconds, for as long as it stayed down.
const state = stateRow({
online: 0,
reachable: 0,
updatedAt: new Date(NOW - 5_000).toISOString(),
lastSeenAt: new Date(NOW - 3 * 86400_000).toISOString(),
})
const shaped = servers.shapePublic(serverRow(), state, NOW)
assert.strictEqual(shaped.lastSeenAt, new Date(NOW - 3 * 86400_000).toISOString())
assert.strictEqual(shaped.stale, false, 'the row itself is fresh — it was written five seconds ago')
assert.strictEqual(shaped.online, false)
// A server nothing has ever heard from has no such moment, and `null` is what
// a page renders as "never" rather than as the epoch.
assert.strictEqual(servers.shapePublic(serverRow(), undefined, NOW).lastSeenAt, null)
})
test('the public shape carries the current wipe, from the state row', () => {
withCore()
const servers = require('../model/servers/servers.model')
// The wipe id on the STATE row, not the newest row in `rust_wipes`. The two
// usually agree, and the state row is the one that is right when they do not:
// the wipe list is derived from events that have been ingested, so a server
// that has just wiped and said nothing since has a new id here and no row there.
const shaped = servers.shapePublic(serverRow(), stateRow({ wipeId: 'w-2026-09', saveCreatedAt: '2026-09-04T18:00:00Z' }), NOW)
assert.strictEqual(shaped.wipeId, 'w-2026-09')
assert.strictEqual(shaped.wipedAt, '2026-09-04T18:00:00Z')
// A server nothing has polled yet has no wipe, and `null` is the honest answer
// — an empty string would be sent back as `?wipe=`, which asks a different
// question and answers nothing.
const never = servers.shapePublic(serverRow(), undefined, NOW)
assert.strictEqual(never.wipeId, null)
assert.strictEqual(never.wipedAt, null)
})
test('a disabled server is not there, rather than forbidden', async () => {
withCore()
const db = require('../model/servers/servers.db')
const model = require('../model/servers/servers.model')
const originalServer = db.getServer
const originalState = db.getState
db.getState = async () => stateRow()
try {
// The detail route is the only one under `/servers/:id` that can say "no such
// server" — the other four answer an empty list, because an unknown id
// genuinely has no events. So what `null` means here decides what a page
// renders, and a disabled server and a missing one must mean the same thing:
// an operator who switched a server off did not switch it into a 403.
db.getServer = async () => ({ ...serverRow(), enabled: 0 })
assert.strictEqual(await model.getPublic('main', NOW), null)
db.getServer = async () => null
assert.strictEqual(await model.getPublic('nope', NOW), null)
// And an id nobody asked about never reaches the database.
let asked = false
db.getServer = async () => { asked = true; return null }
assert.strictEqual(await model.getPublic('', NOW), null)
assert.strictEqual(asked, false)
db.getServer = async () => serverRow()
const server = await model.getPublic('main', NOW)
assert.strictEqual(server.id, 'main')
assert.strictEqual(server.online, true)
assert.ok(!Object.prototype.hasOwnProperty.call(server, 'sidecarBaseUrl'))
} finally {
db.getServer = originalServer
db.getState = originalState
}
})
test('the admin shape reports whether a token is stored, never the token', () => {
withCore()
const servers = require('../model/servers/servers.model')

View File

@@ -148,6 +148,290 @@
}
}
},
"/api/v1/admin/users/{id}/rust/links": {
"get": {
"tags": [
"Admin · Users"
],
"summary": "A users linked Steam accounts and their Rust record (admin only)",
"description": "Every Steam account linked to this website user, with the display name the game last saw and, per server, all-time kills / deaths / playtime across every wipe. Fills the admin.users.detail extension slot.",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer"
},
"description": "User id."
}
],
"responses": {
"200": {
"description": "Linked accounts",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RustAdminLinkList"
}
}
}
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
},
"/api/v1/admin/users/{id}/rust/links/{steamId}": {
"delete": {
"tags": [
"Admin · Users"
],
"summary": "Sever a users Steam link (admin only)",
"description": "Staff release a link on this users behalf. It is the counterweight to the site refusing to move a Steam id another account holds: a player who cannot reach that Steam account in game has no other way back. Recorded in the activity log.",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer"
},
"description": "User id."
},
{
"name": "steamId",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "The Steam id to release."
}
],
"responses": {
"200": {
"description": "Unlinked",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"unlinked": {
"type": "boolean",
"example": true
}
}
}
}
}
},
"404": {
"description": "Not linked to this user",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
},
"/api/v1/player/rust/link": {
"post": {
"tags": [
"Player · Rust"
],
"summary": "Link a Steam account with a one-time code from /link in game",
"description": "The player types /link in game, the plugin hands them a six-character code privately, and they enter it here within five minutes. The site asks each configured server in turn until one recognises the code. A Steam account already linked to a different website account is refused rather than moved — the way out is /unlink in game.",
"responses": {
"200": {
"description": "Linked",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RustLinkResult"
}
}
}
},
"400": {
"description": "Unknown or expired code",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "That Steam account is linked to another website account",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"429": {
"description": "Too many link attempts",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Internal Server Error"
},
"503": {
"description": "A server could not be reached — the code is still good",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RustLinkRequest"
}
}
}
}
}
},
"/api/v1/player/rust/links": {
"get": {
"tags": [
"Player · Rust"
],
"summary": "The Steam accounts the caller has linked",
"description": "Every Steam account linked to the signed-in user, newest first. A link is fleet-wide: it is keyed by Steam id, not by server, because a Steam account is one person across every server an operator runs.",
"responses": {
"200": {
"description": "Linked accounts",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RustLinkList"
}
}
}
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
},
"/api/v1/player/rust/links/{steamId}": {
"delete": {
"tags": [
"Player · Rust"
],
"summary": "Release a Steam account the caller has linked",
"description": "Removes the callers own link. Scoped to the caller in the statement, so a link belonging to somebody else answers the same 404 as one that does not exist.",
"parameters": [
{
"name": "steamId",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "The Steam id to release."
}
],
"responses": {
"200": {
"description": "Unlinked",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"unlinked": {
"type": "boolean",
"example": true
}
}
}
}
}
},
"404": {
"description": "Not linked to the caller",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
},
"/api/v1/player/rust/servers": {
"get": {
"tags": [
@@ -196,6 +480,37 @@
}
}
},
"/api/v1/public/rust/servers/{id}": {
"get": {
"tags": [
"Public · Rust"
],
"summary": "One Rust server",
"description": "The same shape the list answers with, for one server, and a `404` when there is no such server or an operator has disabled it. The detail page needs the difference: every other route under this path answers an empty list for an id that does not exist, because an unknown server genuinely has no events and nobody online.",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "The servers slug"
}
],
"responses": {
"200": {
"description": "The server"
},
"404": {
"description": "No such server, or it is disabled"
},
"500": {
"description": "Internal Server Error"
}
}
}
},
"/api/v1/public/rust/servers/{id}/events": {
"get": {
"tags": [
@@ -823,6 +1138,517 @@
}
}
},
"RustLink": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "One Steam account linked to a website user. Never carries a code."
},
"properties": {
"type": "object",
"properties": {
"steamId": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "76561198000000000"
}
}
},
"name": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"nullable": {
"type": "boolean",
"example": true
},
"description": {
"type": "string",
"example": "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": {
"type": "string",
"example": "Wanderer"
}
}
},
"serverId": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"nullable": {
"type": "boolean",
"example": true
},
"description": {
"type": "string",
"example": "Which server minted the code. Not part of the identity — a link is fleet-wide — but it is where a support conversation starts."
},
"example": {
"type": "string",
"example": "main"
}
}
},
"linkedAt": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"format": {
"type": "string",
"example": "date-time"
}
}
}
}
}
}
},
"RustLinkList": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "The Steam accounts one website user holds (GET /player/rust/links)."
},
"properties": {
"type": "object",
"properties": {
"links": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "array"
},
"items": {
"$ref": "#/components/schemas/RustLink"
}
}
}
}
}
}
},
"RustLinkRequest": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"required": {
"type": "array",
"example": [
"code"
],
"items": {
"type": "string"
}
},
"properties": {
"type": "object",
"properties": {
"code": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"description": {
"type": "string",
"example": "The six-character code /link handed the player in game. Good for five minutes, and it works once."
},
"example": {
"type": "string",
"example": "K7M2PQ"
}
}
}
}
}
}
},
"RustLinkResult": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "The result of redeeming a code."
},
"properties": {
"type": "object",
"properties": {
"linked": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"example": {
"type": "boolean",
"example": true
}
}
},
"link": {
"$ref": "#/components/schemas/RustLink"
},
"already": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"description": {
"type": "string",
"example": "True when this Steam id was already linked to the caller — a second press of the button, not an error."
},
"example": {
"type": "boolean",
"example": false
}
}
}
}
}
}
},
"RustAdminLinkList": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "One users Rust identity, for the admin.users.detail panel (GET /admin/users/{id}/rust/links)."
},
"properties": {
"type": "object",
"properties": {
"links": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "array"
},
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"properties": {
"type": "object",
"properties": {
"steamId": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "76561198000000000"
}
}
},
"name": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"nullable": {
"type": "boolean",
"example": true
},
"description": {
"type": "string",
"example": "What the game last saw this player called, falling back to the name recorded at link time."
},
"example": {
"type": "string",
"example": "Wanderer"
}
}
},
"linkedName": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"nullable": {
"type": "boolean",
"example": true
},
"example": {
"type": "string",
"example": "Wanderer"
}
}
},
"serverId": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"nullable": {
"type": "boolean",
"example": true
},
"example": {
"type": "string",
"example": "main"
}
}
},
"linkedAt": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"format": {
"type": "string",
"example": "date-time"
}
}
},
"firstSeen": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"format": {
"type": "string",
"example": "date-time"
},
"nullable": {
"type": "boolean",
"example": true
}
}
},
"lastSeen": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"format": {
"type": "string",
"example": "date-time"
},
"nullable": {
"type": "boolean",
"example": true
}
}
},
"servers": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "array"
},
"description": {
"type": "string",
"example": "All-time totals per server, summed across every wipe."
},
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"properties": {
"type": "object",
"properties": {
"serverId": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "main"
}
}
},
"serverName": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "Main · Vanilla"
}
}
},
"kills": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"example": {
"type": "number",
"example": 41
}
}
},
"deaths": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"example": {
"type": "number",
"example": 37
}
}
},
"npcKills": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"example": {
"type": "number",
"example": 120
}
}
},
"structures": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"example": {
"type": "number",
"example": 64
}
}
},
"playtimeSec": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"example": {
"type": "number",
"example": 43200
}
}
},
"wipes": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"example": {
"type": "number",
"example": 2
}
}
},
"lastSeen": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"format": {
"type": "string",
"example": "date-time"
},
"nullable": {
"type": "boolean",
"example": true
}
}
}
}
}
}
}
}
}
}
}
}
}
}
}
}
}
}
},
"RustSidecarProbe": {
"type": "object",
"properties": {