feat(rust): identity — a link code from the game, and the Steam id inside core's user page #6

Merged
whitlocktech merged 2 commits from feat/phase-6-identity into edge 2026-09-21 22:25:17 +00:00
27 changed files with 2703 additions and 45 deletions

View File

@@ -74,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
@@ -89,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, query }
export default { servers, playerServers, admin, BASE }
export default { servers, playerServers, playerLinks, admin, adminUserLinks, BASE }

View File

@@ -20,7 +20,10 @@ 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.
@@ -54,11 +57,18 @@ const ID = 'rust'
//
// 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: '', element: <Servers /> },
{ path: 'servers/:id', element: <ServerDetail /> },
],
player: [{ path: '', element: <Account /> }],
})
// ── Nav ───────────────────────────────────────────────────────────────────
@@ -83,6 +93,18 @@ registry.registerNav(ID, {
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
@@ -97,6 +119,16 @@ registry.registerNav(ID, {
// 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

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 }

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

@@ -12,5 +12,6 @@
"admin": ["/rust"],
"player": ["/rust"]
},
"capabilities": ["rust", "servers", "killfeed", "leaderboard", "presence", "wipes"]
"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",
@@ -51,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

@@ -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`

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,147 @@
// ── 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. */
async function listForUser(userId) {
return core.query(
`SELECT steam_id AS steamId, user_id AS userId, name, server_id AS serverId,
linked_at AS linkedAt
FROM ${LINKS}
WHERE user_id = ?
ORDER BY 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,247 @@
// ── 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. */
async function listForUser(userId) {
return (await db.listForUser(userId)).map(shape)
}
/** 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

@@ -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({ error: '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({ error: '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({ error: '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')
@@ -19,4 +35,103 @@ async function listServers(req, res) {
}
}
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({ error: '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({
error: 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({
error:
'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({
error: 'The game servers are unreachable right now — try again in a minute.',
})
case 'no-servers':
return res.status(503).json({ error: 'No Rust servers are configured on this site yet.' })
default:
return res.status(400).json({
error: '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({ error: '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({ error: '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({ error: '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

@@ -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

@@ -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)
})

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

@@ -0,0 +1,276 @@
// ── 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'])
})

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": [
@@ -854,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": {