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
138 lines
5.5 KiB
JavaScript
138 lines
5.5 KiB
JavaScript
// ── 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 player’s 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 }
|