feat(rust): identity — a link code from the game, and the Steam id inside core's user page #6
@@ -74,6 +74,20 @@ export const playerServers = {
|
|||||||
list: () => req('/player/rust/servers'),
|
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 ─────────────────────────────────────────────────────────────────
|
// ── admin ─────────────────────────────────────────────────────────────────
|
||||||
// **`sidecarToken` goes up and never comes back.** The list answers `hasToken`,
|
// **`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
|
// 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' }),
|
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
|
// Exported for the rare caller that needs the base itself — an `<img src>`, a
|
||||||
// download link, an EventSource. Reach for `request` first.
|
// download link, an EventSource. Reach for `request` first.
|
||||||
export { BASE, query }
|
export { BASE, query }
|
||||||
|
|
||||||
export default { servers, playerServers, admin, BASE }
|
export default { servers, playerServers, playerLinks, admin, adminUserLinks, BASE }
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ import { registry, coreApiVersion } from './core.js'
|
|||||||
|
|
||||||
import Servers from './routes/public/Servers.jsx'
|
import Servers from './routes/public/Servers.jsx'
|
||||||
import ServerDetail from './routes/public/ServerDetail.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 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
|
// The module id, exactly as `module.json` spells it. Core keys the registry by it
|
||||||
// and prefixes every route path with 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
|
// React Router ranks a static segment above a dynamic one, so `/rust` wins
|
||||||
// against core's `/:slug` CMS route without depending on registration order.
|
// 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, {
|
registry.registerRoutes(ID, {
|
||||||
public: [
|
public: [
|
||||||
{ path: '', element: <Servers /> },
|
{ path: '', element: <Servers /> },
|
||||||
{ path: 'servers/:id', element: <ServerDetail /> },
|
{ path: 'servers/:id', element: <ServerDetail /> },
|
||||||
],
|
],
|
||||||
|
player: [{ path: '', element: <Account /> }],
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Nav ───────────────────────────────────────────────────────────────────
|
// ── Nav ───────────────────────────────────────────────────────────────────
|
||||||
@@ -83,6 +93,18 @@ registry.registerNav(ID, {
|
|||||||
items: [{ label: 'Servers', to: '/rust' }],
|
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 ───────────────────────────────────────────────────────
|
// ── Extension slots ───────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Core declares a slot, only core may declare one, and at most one module may
|
// Core declares a slot, only core may declare one, and at most one module may
|
||||||
@@ -97,6 +119,16 @@ registry.registerNav(ID, {
|
|||||||
// purpose.
|
// purpose.
|
||||||
registry.registerExtension(ID, 'site.footer.status', FooterStatus)
|
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
|
// `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
|
// 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
|
// between the core that validated the manifest and the core that published this
|
||||||
|
|||||||
49
client/src/icons.jsx
Normal file
49
client/src/icons.jsx
Normal 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 }
|
||||||
147
client/src/routes/admin/UserRustSections.jsx
Normal file
147
client/src/routes/admin/UserRustSections.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
191
client/src/routes/player/Account.jsx
Normal file
191
client/src/routes/player/Account.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,5 +12,6 @@
|
|||||||
"admin": ["/rust"],
|
"admin": ["/rust"],
|
||||||
"player": ["/rust"]
|
"player": ["/rust"]
|
||||||
},
|
},
|
||||||
"capabilities": ["rust", "servers", "killfeed", "leaderboard", "presence", "wipes"]
|
"extensions": ["admin.users.detail"],
|
||||||
|
"capabilities": ["rust", "servers", "killfeed", "leaderboard", "presence", "wipes", "identity"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,31 @@
|
|||||||
"path": "/api/v1/admin/rust/servers/:id",
|
"path": "/api/v1/admin/rust/servers/:id",
|
||||||
"tier": "public"
|
"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",
|
"method": "GET",
|
||||||
"path": "/api/v1/admin/rust/servers",
|
"path": "/api/v1/admin/rust/servers",
|
||||||
"tier": "public"
|
"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",
|
"method": "GET",
|
||||||
"path": "/api/v1/player/rust/servers",
|
"path": "/api/v1/player/rust/servers",
|
||||||
@@ -51,6 +71,11 @@
|
|||||||
"path": "/api/v1/admin/rust/servers/:id/test",
|
"path": "/api/v1/admin/rust/servers/:id/test",
|
||||||
"tier": "public"
|
"tier": "public"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/player/rust/link",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "PUT",
|
"method": "PUT",
|
||||||
"path": "/api/v1/admin/rust/servers/:id",
|
"path": "/api/v1/admin/rust/servers/:id",
|
||||||
|
|||||||
@@ -69,9 +69,16 @@ const STAFF_KINDS = Object.freeze([
|
|||||||
'player.unbanned',
|
'player.unbanned',
|
||||||
'player.login.attempt',
|
'player.login.attempt',
|
||||||
'player.approved',
|
'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 ALL_KINDS = Object.freeze([...PUBLIC_KINDS, ...STAFF_KINDS])
|
||||||
|
|
||||||
const PUBLIC = new Set(PUBLIC_KINDS)
|
const PUBLIC = new Set(PUBLIC_KINDS)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
-- it knows this module registered, because it is the side that knows which
|
-- it knows this module registered, because it is the side that knows which
|
||||||
-- registrant owned what.
|
-- registrant owned what.
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS rust_account_links;
|
||||||
DROP TABLE IF EXISTS rust_ingest_cursor;
|
DROP TABLE IF EXISTS rust_ingest_cursor;
|
||||||
DROP TABLE IF EXISTS rust_presence;
|
DROP TABLE IF EXISTS rust_presence;
|
||||||
DROP TABLE IF EXISTS rust_events;
|
DROP TABLE IF EXISTS rust_events;
|
||||||
|
|||||||
@@ -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 ────────────────────────────────
|
-- ── Changes to tables that already shipped ────────────────────────────────
|
||||||
--
|
--
|
||||||
-- An ALTER below the CREATE, never an edit to it: `CREATE TABLE IF NOT EXISTS`
|
-- An ALTER below the CREATE, never an edit to it: `CREATE TABLE IF NOT EXISTS`
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ module.exports = function register(ctx, api) {
|
|||||||
const publicRust = require('./router/public/rust.router')
|
const publicRust = require('./router/public/rust.router')
|
||||||
const playerRust = require('./router/player/rust.router')
|
const playerRust = require('./router/player/rust.router')
|
||||||
const adminRust = require('./router/admin/rust.router')
|
const adminRust = require('./router/admin/rust.router')
|
||||||
|
const usersRust = require('./router/admin/usersRust.router')
|
||||||
const boot = require('./boot')
|
const boot = require('./boot')
|
||||||
/* eslint-enable global-require */
|
/* eslint-enable global-require */
|
||||||
|
|
||||||
@@ -78,6 +79,20 @@ module.exports = function register(ctx, api) {
|
|||||||
admin: { '/rust': adminRust },
|
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
|
// 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
|
// 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.
|
// 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
|
// Everything else this module will register — the Team provider, the event
|
||||||
// triggers and audiences, the engagement seeds, the four event catalogues, the
|
// triggers and audiences, the engagement seeds, the four event catalogues, the
|
||||||
// notification streams, the slash commands and the two extension slots — is
|
// notification streams and the slash commands — is deliberately absent. Each
|
||||||
// deliberately absent. Each arrives with the phase that has something real to
|
// arrives with the phase that has something real to put in it. A registration
|
||||||
// put in it. A registration with nothing behind it is worse than a missing one:
|
// with nothing behind it is worse than a missing one: a declared trigger
|
||||||
// a declared trigger nothing emits and a declared slot nothing fills are both
|
// nothing emits and a declared slot nothing fills are both surfaces an operator
|
||||||
// surfaces an operator can configure and then wait on.
|
// can configure and then wait on.
|
||||||
|
|
||||||
log.info('registered', {
|
log.info('registered', {
|
||||||
version: require('../module.json').version,
|
version: require('../module.json').version,
|
||||||
routes: 'public:/rust player:/rust admin:/rust',
|
routes: 'public:/rust player:/rust admin:/rust',
|
||||||
|
extensions: 'admin.users.detail',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
const core = require('./core')
|
const core = require('./core')
|
||||||
|
|
||||||
const db = require('./model/events/events.db')
|
const db = require('./model/events/events.db')
|
||||||
|
const links = require('./model/links/links.model')
|
||||||
const sidecar = require('./sidecarClient')
|
const sidecar = require('./sidecarClient')
|
||||||
|
|
||||||
const log = core.logger('ingest')
|
const log = core.logger('ingest')
|
||||||
@@ -144,6 +145,34 @@ async function apply(serverId, item) {
|
|||||||
await db.touchPlayer(frame.steamId, frame.name || null)
|
await db.touchPlayer(frame.steamId, frame.name || null)
|
||||||
break
|
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:
|
default:
|
||||||
// Stored, not counted. Moderation frames, the server lifecycle, and
|
// Stored, not counted. Moderation frames, the server lifecycle, and
|
||||||
// anything a newer protocol sends that this build does not understand.
|
// anything a newer protocol sends that this build does not understand.
|
||||||
|
|||||||
147
server/model/links/links.db.js
Normal file
147
server/model/links/links.db.js
Normal 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,
|
||||||
|
}
|
||||||
247
server/model/links/links.model.js
Normal file
247
server/model/links/links.model.js
Normal 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,
|
||||||
|
}
|
||||||
71
server/router/admin/usersRust.controller.js
Normal file
71
server/router/admin/usersRust.controller.js
Normal 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 user’s Rust links', { error: err.message })
|
||||||
|
res.status(500).json({ error: 'Failed to read this user’s 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 }
|
||||||
73
server/router/admin/usersRust.router.js
Normal file
73
server/router/admin/usersRust.router.js
Normal 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 user’s 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 user’s Steam link (admin only)'
|
||||||
|
// #swagger.description = 'Staff release a link on this user’s 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
|
||||||
@@ -1,11 +1,27 @@
|
|||||||
// ── Player · Rust — the handlers ──────────────────────────────────────────
|
// ── Player · Rust — the handlers ──────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// See the router for why this tier is thin in phase 1. The one thing it must not
|
// Two things live here now: the server list as a signed-in caller sees it (phase
|
||||||
// do is reshape the list itself: it calls the same model the public tier does, so
|
// 1's honest placeholder, which must not reshape the list — it calls the same
|
||||||
// the two answers cannot drift while they are meant to be 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 core = require('../../core')
|
||||||
|
|
||||||
|
const links = require('../../model/links/links.model')
|
||||||
const servers = require('../../model/servers/servers.model')
|
const servers = require('../../model/servers/servers.model')
|
||||||
|
|
||||||
const log = core.logger('player')
|
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 player’s 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 }
|
||||||
|
|||||||
@@ -4,38 +4,109 @@
|
|||||||
// sits behind `noindex, requireAuth`, so every handler here has a signed-in user
|
// sits behind `noindex, requireAuth`, so every handler here has a signed-in user
|
||||||
// and none of them re-implements that check.
|
// 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
|
// 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**
|
// `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
|
// directions — a declared prefix that never gets a router fails the load. So the
|
||||||
// declaration and the registration land together or not at all.
|
// 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
|
// Phase 1 said this tier would carry the signed-in view of a server — the
|
||||||
// linked Steam identity, their own presence, their own entitlements. None of that
|
// viewer's own linked Steam identity, their own presence, their own entitlements
|
||||||
// exists yet — identity is a later phase — so the one route here answers the
|
// — and that identity was a later phase. This is that phase: `/links`, `/link`
|
||||||
// server list as the signed-in caller sees it, which is currently the same list
|
// and `DELETE /links/:steamId` are R1, and everything phases 7 and 13 hand out is
|
||||||
// the public tier serves.
|
// hung off the row they write.
|
||||||
//
|
//
|
||||||
// That is deliberately a real route and not a placeholder: it is the URL the app
|
// `/servers` stays what it was: the same list the public tier serves, answered on
|
||||||
// and the SPA will call, and it starts answering correctly now rather than
|
// the authenticated tier so per-player detail can be added without moving the
|
||||||
// changing address later. What it must not become is a second copy of the public
|
// address. It delegates to the same model, so the two cannot drift.
|
||||||
// shape — it delegates to the same model, so the two cannot drift.
|
|
||||||
|
|
||||||
const core = require('../../core')
|
const core = require('../../core')
|
||||||
|
|
||||||
const express = core.express
|
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()
|
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(
|
playerRustRouter.get(
|
||||||
'/servers',
|
'/servers',
|
||||||
// #swagger.tags = ['Player · Rust']
|
// #swagger.tags = ['Player · Rust']
|
||||||
// #swagger.summary = 'The Rust servers, for a signed-in player'
|
// #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.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" } } } } */
|
/* #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 caller’s 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
|
module.exports = playerRustRouter
|
||||||
|
|||||||
@@ -60,6 +60,22 @@ const TIER_BASE = {
|
|||||||
player: '/api/v1/player',
|
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 }]`.
|
* 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 }) => {
|
return mounts.map(({ router, prefix, what }) => {
|
||||||
const file = fileOf(router)
|
const file = fileOf(router)
|
||||||
if (!file) {
|
if (!file) {
|
||||||
|
|||||||
@@ -52,18 +52,19 @@ const TIMEOUT_MS = 12000
|
|||||||
* here, `PROTOCOL_VERSION` in the sidecar, `ProtocolVersion` in the bridge
|
* here, `PROTOCOL_VERSION` in the sidecar, `ProtocolVersion` in the bridge
|
||||||
* plugin, and `protocol` in its `overlay.toml`.
|
* plugin, and `protocol` in its `overlay.toml`.
|
||||||
*
|
*
|
||||||
* **2 — the read path.** The bump lands here in the same change as the emitters,
|
* **3 — identity.** Protocol 2 was the read path; 3 adds the first message the
|
||||||
* even though this module does not yet consume any of the new frames: the
|
* WEBSITE originates (`link.confirm`) and the two account frames the plugin
|
||||||
* sidecar refuses a client declaring a different version with a `409`, so a
|
* emits beside it. The bump lands here in the same change as the emitters,
|
||||||
* module left on 1 would stop being able to read the server board it has been
|
* because the sidecar refuses a client declaring a different version with a
|
||||||
* reading all along. A constant that lags the deployment is not a safe default;
|
* `409`: a module left on 2 would stop being able to read the server board it
|
||||||
* it is an outage with a version number on 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
|
* 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
|
* deployment into a `409` naming both numbers instead of a parse failure three
|
||||||
* layers further in.
|
* 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. */
|
/** What a caller gets back. Shaped once so every call site reads the same. */
|
||||||
function reply(ok, status, data = null) {
|
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. */
|
/** Where the sidecar's history currently ends. What a new server's cursor starts at. */
|
||||||
const feedTail = (server) => request(server, '/feed')
|
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 = {
|
module.exports = {
|
||||||
TIMEOUT_MS,
|
TIMEOUT_MS,
|
||||||
PROTOCOL_VERSION,
|
PROTOCOL_VERSION,
|
||||||
@@ -199,5 +220,6 @@ module.exports = {
|
|||||||
boards,
|
boards,
|
||||||
feed,
|
feed,
|
||||||
feedTail,
|
feedTail,
|
||||||
|
confirmLink,
|
||||||
joinUrl,
|
joinUrl,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,6 +100,101 @@ module.exports = {
|
|||||||
stale: { type: 'boolean', example: false },
|
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 user’s 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: {
|
RustSidecarProbe: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
description: 'What a sidecar said when probed (POST /admin/rust/servers/{id}/test).',
|
description: 'What a sidecar said when probed (POST /admin/rust/servers/{id}/test).',
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ test('an unknown kind is not public — the default is deny', () => {
|
|||||||
assert.equal(catalogue.isPublic('player.location'), false)
|
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 [
|
for (const kind of [
|
||||||
'player.login.attempt',
|
'player.login.attempt',
|
||||||
'player.approved',
|
'player.approved',
|
||||||
@@ -36,6 +36,11 @@ test('nothing carrying an IP address or a report is public', () => {
|
|||||||
'player.unbanned',
|
'player.unbanned',
|
||||||
'player.reported',
|
'player.reported',
|
||||||
'entity.destroyed',
|
'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.equal(catalogue.isPublic(kind), false, `${kind} must not be public`)
|
||||||
assert.ok(catalogue.STAFF_KINDS.includes(kind), `${kind} must be classified, not merely absent`)
|
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)
|
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
|
// 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
|
// 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.
|
// without deciding who may see it has to fail somewhere, and this is where.
|
||||||
//
|
//
|
||||||
// Sourced from docs/rust-link/PROTOCOL.md §8.4.
|
// Sourced from docs/rust-link/PROTOCOL.md §8.4.
|
||||||
const PROTOCOL_2 = [
|
const PROTOCOL_3 = [
|
||||||
'player.connected',
|
'player.connected',
|
||||||
'player.disconnected',
|
'player.disconnected',
|
||||||
'player.respawned',
|
'player.respawned',
|
||||||
@@ -104,7 +109,9 @@ test('the classification covers exactly the kinds protocol 2 defines', () => {
|
|||||||
'server.wipe',
|
'server.wipe',
|
||||||
'server.initialized',
|
'server.initialized',
|
||||||
'server.shutdown',
|
'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())
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -157,21 +157,50 @@ test('every path in the fragment is fully qualified', () => {
|
|||||||
|
|
||||||
test("the manifest and the module's declared mounts agree", () => {
|
test("the manifest and the module's declared mounts agree", () => {
|
||||||
const { routes } = JSON.parse(fs.readFileSync(MANIFEST, 'utf8'))
|
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 = []
|
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}/`)
|
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
|
// **The exception this test predicted, now grown deliberately.** Phase 6 fills
|
||||||
// exception here yet, and that is the point of asserting it now: phase 6 adds
|
// `admin.users.detail`, whose routes live on a resource CORE owns
|
||||||
// the `admin.users.detail` extension slot, whose routes live under core's
|
// (`/api/v1/admin/users/:id`) rather than under any mount of ours — §2.4's
|
||||||
// `/api/v1/admin/users/` rather than under any mount of ours (§2.4). When that
|
// fourth mount shape. So a route is legitimate if it is under a declared
|
||||||
// arrives this test must grow the exception deliberately, rather than a route
|
// prefix, or under the mount of a slot this module declares.
|
||||||
// outside every declared mount arriving unnoticed.
|
//
|
||||||
|
// 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) {
|
for (const route of routes) {
|
||||||
const under = declared.some((d) => route.path.startsWith(d))
|
if (declared.some((d) => route.path.startsWith(d))) continue
|
||||||
assert.ok(under, `${route.method} ${route.path} is served from outside every mount module.json declares`)
|
|
||||||
|
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`)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
82
server/test/identityRoutes.test.js
Normal file
82
server/test/identityRoutes.test.js
Normal 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 parent’s 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`)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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[0].sql, /^DELETE FROM rust_presence/)
|
||||||
assert.match(presence[1].sql, /INSERT INTO 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
276
server/test/links.test.js
Normal 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'])
|
||||||
|
})
|
||||||
@@ -148,6 +148,290 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/admin/users/{id}/rust/links": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Admin · Users"
|
||||||
|
],
|
||||||
|
"summary": "A user’s 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 user’s Steam link (admin only)",
|
||||||
|
"description": "Staff release a link on this user’s 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 caller’s 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": {
|
"/api/v1/player/rust/servers": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"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 user’s 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": {
|
"RustSidecarProbe": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
Reference in New Issue
Block a user