Files
Module-Rust/server/model/links/links.model.js
wtclaude 0876a1d568
All checks were successful
PR Checks / server-tests (pull_request) Successful in 15s
PR Checks / frozen-manifest (pull_request) Successful in 48s
PR Checks / client-build (pull_request) Successful in 7m49s
fix(rust): answer refusals in the field core reads, and show the name the game last saw
The two defects the phase 6 browser walk found and #6 described but did not
carry. They were written, walked and left uncommitted; `edge` still has the
shapes the walk condemned.

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
2026-09-21 17:34:21 -05:00

259 lines
9.9 KiB
JavaScript

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