Files
Module-Rust/server/router/player/rust.controller.js
wtclaude baffaa46c9 feat(rust): identity — a link code from the game, and the Steam id inside core's user page
R1's identity link, site-side, and R13's first extension slot. A player types
/link in game, the plugin hands them a six-character code privately, and they
enter it here; the site records who owns which Steam account, and an operator
sees that on core's own `/admin/users/:id` page.

**The site is the author of record and the game holds nothing.** There is no
per-account store in Rust that survives a wipe, and phase 7 needs the site
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.

## D24 — a code is minted by ONE server, so every server is asked

Nothing in six characters says where it came from. The fleet is asked in turn and
the first `link.ok` wins; the others answer `unknown` and nothing happens there,
because a code is only spent at the server that actually holds it. Asking the
player to pick was rejected: a wrong pick would come back indistinguishable from
a wrong code, and that is the one refusal which must not be ambiguous.

**"Every reachable server refused" is not the same answer as "a server was
unreachable."** Collapsing them tells a player whose server is down that their
code is wrong — so they run /link again on that same server and are told the same
thing for as long as it stays down. `unsure` is that case, and it says to try
again rather than to fetch a new code.

## D23 — a Steam id another account holds is refused, never moved

The primary key is `steam_id`, and it is load-bearing rather than tidy: phase 7
grants permissions against a link and phase 13 hangs entitlements off it, so a
silent move is an account takeover performed by typing six characters. The
refusal names the holder, because the advice is unusable without it. The INSERT
is a plain INSERT for the same reason — `ON DUPLICATE KEY UPDATE` here would BE
that move — and the duplicate-key error is the refusal for the race the check
above cannot close.

The way out is `/unlink` in game, which reaches the site off the ingest feed
rather than through a route (the plugin has no link to delete). D25 adds the
other way out: staff can sever a link from the admin panel, for a player who
cannot reach that Steam account in game.

## The slot, and the hole it found in this repo's own generator

`admin.users.detail` is declared in `module.json` AND registered in `index.js`
AND filled by the chunk — three places, because the server half and the client
half are different registrations that share one name.

`swaggerFragment.js` knew only about tier routers, so the two routes under
`/admin/users/:id` were generated by nothing: a fragment that was internally
consistent and described two routes fewer than the module serves. A slot's mount
is core's and cannot be derived here, so it is a fourth constant beside
`TIER_BASE` — held to account by the frozen-manifest job, which was verified to
catch exactly this by removing the two paths and watching it fail.

## Smaller things worth knowing

- **Core's `useAsync` has no `refresh`.** 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.
- **Every player-portal nav row needs an `icon`** — core draws one on every row,
  and the client suite says so. This module had no icons file until now, because
  the public header is text buttons.
- The two new frame kinds are STAFF-only. Neither carries a code, but both name a
  Steam id beside a website account's activity, and that join is not a public
  fact about what happened on a server.
- The link code route carries its own rate limiter rather than core's
  `accountChangeLimiter`: this is guessing somebody else's secret, not changing
  your own password, and a shared counter would let one policy set the other.

Protocol 3 on all three declaration sites; 17 new tests, 136 green.

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

138 lines
5.4 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({ error: '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({ 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 }