// ── Who owns which Steam account ────────────────────────────────────────── // // R1's identity link, site-side. The flow it sits in the middle of: // // 1. In game, a player types `/link`. The plugin mints a one-time code, tells // them privately, and holds it in memory for five minutes. // 2. On the website, the player types that code. This module asks the sidecar, // which asks the plugin, which answers with the Steam id the code belongs // to and drops it. // 3. This file records the result. // // **The site is the author of record and the game holds nothing.** That is the // one real difference from the UO bridge, which writes a tag onto the game // account: there is no equivalent per-account store in Rust that survives a wipe, // and phase 7 needs the site to be 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. const core = require('../../core') const db = require('./links.db') const servers = require('../servers/servers.model') const sidecar = require('../../sidecarClient') const log = core.logger('links') /** What a link looks like to any caller. Never carries a raw code. */ function shape(row) { if (!row) return null return { steamId: row.steamId, name: row.name || null, serverId: row.serverId || null, linkedAt: row.linkedAt, } } /** * The Steam accounts one website user holds. * * The name is the one the GAME last saw, falling back to the one recorded when * they linked — the rule the admin panel already used, applied on the page the * player themselves reads. A browser walk found the two disagreeing: staff saw * `Wanderer` and the player saw `Wanderer-old`, for the same person on the same * site. */ async function listForUser(userId) { return (await db.listForUser(userId)).map((row) => ({ ...shape(row), name: row.playerName || row.name || null, })) } /** True when this user holds this Steam id. The ownership gate every player read uses. */ async function owns(steamId, userId) { const row = await db.getBySteamId(steamId) return Boolean(row && Number(row.userId) === Number(userId)) } /** * Redeem a code against one server, and record the link. * * Answers a discriminated result rather than throwing, because every outcome * here is a sentence somebody has to read: * * `{ ok: true, link }` — linked * `{ ok: false, reason: 'rejected' }`— the game says that code is not good * `{ ok: false, reason: 'taken', username }` — someone else holds that Steam id * `{ ok: false, reason: 'offline' }` — the game or its sidecar did not answer * * **`rejected` deliberately collapses "unknown" and "expired".** The plugin * distinguishes them and an operator reading its log can too; a stranger typing * codes must not learn which of the two they hit, because that is the difference * between "keep guessing" and "guess faster". */ async function confirmOne({ server, code, userId }) { const result = await sidecar.confirmLink(server, code) // The transport failed: the sidecar is unreachable, the game is not connected, // or the reply never came. None of those is a verdict on the code, so the // player is told to try again rather than that their code is wrong. if (!result.ok) { log.warn('link confirm did not reach the game', { server: server.id, status: result.status }) return { ok: false, reason: 'offline' } } const frame = result.data || {} // The plugin's own refusal. `frame.reason` is `unknown`, `expired` or // `malformed`; it is logged and not surfaced (see the doc above). if (frame.kind !== 'link.ok' || !frame.steamId) { log.info('link code refused', { server: server.id, reason: frame.reason || frame.kind || 'unknown' }) return { ok: false, reason: 'rejected' } } const steamId = String(frame.steamId) const held = await db.getBySteamId(steamId) // D23: refuse, and say whose it is. A move would transfer every permission and // entitlement phases 7 and 13 hang off this link, on a code anybody in game // could have run — and the player's way out is `/unlink` in game, which they // can reach from the machine they are sitting at. if (held) { if (Number(held.userId) === Number(userId)) { // Already theirs. Not an error: a player who pressed the button twice, or // one whose code was confirmed on a request that then timed out. return { ok: true, link: shape(held), already: true } } return { ok: false, reason: 'taken', username: held.username } } try { await db.insert({ steamId, userId, name: frame.name || null, serverId: server.id, }) } catch (err) { // The race the PRIMARY KEY exists for: two confirmations of the same Steam // id, interleaved between the check above and this write. The key refuses the // second and it becomes the same refusal, rather than a 500. if (err && (err.code === 'ER_DUP_ENTRY' || err.errno === 1062)) { const now = await db.getBySteamId(steamId) if (now && Number(now.userId) === Number(userId)) { return { ok: true, link: shape(now), already: true } } return { ok: false, reason: 'taken', username: now && now.username } } throw err } const link = shape(await db.getBySteamId(steamId)) log.info('steam account linked', { steamId, userId, server: server.id }) return { ok: true, link } } /** * Redeem a code against the fleet (D24). * * **A code is minted by ONE server and the player types six characters into a * browser**, so the site cannot know which server it came from — nothing in the * code says, and asking the player to pick would make a wrong guess * indistinguishable from a wrong code, which is the one refusal that must not be * ambiguous. So every enabled server is asked in turn and the first `link.ok` * wins. The others answer `unknown` and nothing happens there: a code is only * spent at the server that actually holds it. * * The loop stops early on `taken`, because that is a verdict about the Steam id * rather than about this server — asking the rest of the fleet would produce the * same answer more slowly. * * **"Every reachable server refused" is not the same answer as "a server was * unreachable"**, and collapsing them is how a player who linked on the one * server that is down gets told their code is wrong. `unsure` is that case, and * the sentence it earns says to try again rather than to run `/link` again. */ async function redeem({ code, userId }) { const fleet = await servers.listForPolling() if (fleet.length === 0) return { ok: false, reason: 'no-servers' } let refused = 0 let unreachable = 0 for (const server of fleet) { // Sequential, deliberately. In parallel every server would be asked even // after one had already answered, and a code spent on the right server would // still be travelling to five others — for a fleet of six and a five-minute // TTL, there is nothing to win by racing them. // eslint-disable-next-line no-await-in-loop const result = await confirmOne({ server, code, userId }) if (result.ok || result.reason === 'taken') return result if (result.reason === 'offline') unreachable += 1 else refused += 1 } if (refused === 0) return { ok: false, reason: 'offline' } if (unreachable > 0) return { ok: false, reason: 'unsure' } return { ok: false, reason: 'rejected' } } /** Remove a link the caller owns. False when they did not hold it. */ async function unlinkOwned(steamId, userId) { return (await db.removeOwned(steamId, userId)) > 0 } /** * Remove a link whoever holds it. * * Two callers, both of which have already established their authority and * neither of which is the link's owner: ingest applying an in-game `/unlink` * (the authority is the Steam account — whoever is connected as it is who it * is), and a staff unlink from the `admin.users.detail` panel (D25). * * It logs nothing about who asked, because the two callers record that * differently: the admin one writes an `activity.log` entry naming the operator, * and the game one has no operator to name. */ async function unlinkAnyOwner(steamId) { return (await db.removeBySteamId(steamId)) > 0 } /** * Remove a link because the player asked in game. * * Called from ingest, off an `account.unlinked` event. */ async function unlinkFromGame(steamId) { const removed = await unlinkAnyOwner(steamId) if (removed) log.info('steam account unlinked in game', { steamId }) return removed } /** The admin panel's read: every link this user holds, with per-server totals. */ async function forAdmin(userId) { const links = await db.listForUserWithPlayer(userId) return Promise.all( links.map(async (row) => ({ steamId: row.steamId, // The name on the LINK is what they were called when they linked; the one // on `rust_players` is what the game last saw. They differ the moment // somebody renames, and the newer one is the useful one to show. name: row.playerName || row.name || null, linkedName: row.name || null, serverId: row.serverId || null, linkedAt: row.linkedAt, firstSeen: row.firstSeen || null, lastSeen: row.lastSeen || null, servers: (await db.statsForSteamId(row.steamId)).map((s) => ({ serverId: s.serverId, serverName: s.serverName || s.serverId, kills: Number(s.kills) || 0, deaths: Number(s.deaths) || 0, npcKills: Number(s.npcKills) || 0, structures: Number(s.structures) || 0, playtimeSec: Number(s.playtimeSec) || 0, wipes: Number(s.wipes) || 0, lastSeen: s.lastSeen || null, })), })), ) } module.exports = { shape, listForUser, owns, confirmOne, redeem, unlinkOwned, unlinkAnyOwner, unlinkFromGame, forAdmin, }