Files
Module-Rust/server/router/player/rust.controller.js
wtclaude 383e89442e feat(rust): what the site has given a player, as the player reads it
Phase 8's website half. Phase 7 made the site the author of in-game
privilege and gave an operator every view of it; this is the other side,
and it is the first time a player can see what they hold without asking
one.

`GET /player/rust/permissions` is self-scoped in SQL and read-only by
construction — a grant a player could change would not be a grant. Three
things make it a different shape from the admin read rather than a
filtered one:

  * the scope arithmetic is answered on the server. A client handed `*`
    would have to know what the fleet is to say anything, and then
    `inScope` exists twice. Each entry carries the servers it reaches,
    already resolved and already marked.
  * `live` is the pushed ledger, never the authored row. A grant is not a
    privilege in a game until a sync confirmed it, and phase 7 is careful
    never to record a push that silently did nothing — so "waiting" is
    honest, and the alternative is the site claiming to have given
    something it has not.
  * nothing says WHY it is waiting. An offline server, a permission no
    loaded plugin registered and a store that has never seen the account
    all look the same from here; telling them apart is an operator's
    diagnosis and an inventory of what is installed.

An entitlement that reaches nobody still lists, and the page says so —
authored against the website account, it exists before a Steam id does,
and hiding it until one turns up is the defect the admin user page
shipped in phase 7 (PLAN.md §20.5).

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

172 lines
6.8 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 permissions = require('../../model/permissions/permissions.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' })
}
}
/**
* GET /player/rust/permissions — what the site has given this player in game.
*
* Phase 7 made the website the author of in-game privilege and gave an operator
* every view of it; this is the other side of that, and it is the first time a
* player can see what they hold without asking one. Read-only by construction:
* nothing a player can do here changes a grant, because a grant they could
* change would not be a grant.
*
* The caller's Steam ids come from the link model rather than the permission
* one, so the two questions stay in the files that own them — and the pushed
* ledger is keyed by Steam id, which is the whole reason this read needs them.
*/
async function listPermissions(req, res) {
try {
const [accounts, serverRows] = await Promise.all([
links.listForUser(req.user.id),
servers.listPublic(),
])
const held = await permissions.forPlayer(
req.user.id,
accounts.map((account) => account.steamId),
serverRows,
)
res.json({ ...held, accounts: accounts.length })
} catch (err) {
log.error('failed to read a player’s entitlements', { error: err.message })
res.status(500).json({ message: 'Failed to read what you hold in game' })
}
}
module.exports = { listServers, listLinks, confirmLink, removeLink, listPermissions }