Files
Module-Rust/server/router/player/rust.controller.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

138 lines
5.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

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