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
172 lines
6.8 KiB
JavaScript
172 lines
6.8 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 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 }
|