Registers core's MODULE_API 1.17.0 identity hooks (coreApi ^1.17.0): onIdentityLinking refuses a Steam id that is another website account's Rust link (D396); onIdentityLinked writes the Rust link with the PRIMARY KEY as the race guard, reconciles Teams and raises the linked notice, as /link does. Unlinking Steam leaves the Rust link (D393); the Rust account page says so. ci/core-ref.json still pins the 1.15.0 core and must move to the website branch head carrying 1.17.0 before CI can pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
398 lines
17 KiB
JavaScript
398 lines
17 KiB
JavaScript
// ── 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 engagement = require('../../engagement/emit')
|
|
const servers = require('../servers/servers.model')
|
|
const serversDb = require('../servers/servers.db')
|
|
const sidecar = require('../../sidecarClient')
|
|
|
|
const log = core.logger('links')
|
|
|
|
/**
|
|
* How far back a code's mint counts (F5): the plugin's five-minute `CodeTtl`,
|
|
* plus a minute for a frame that reached this site late — a sidecar that
|
|
* reconnected, a cursor catching up. Too wide costs nothing but the old
|
|
* "unsure" answer for a little longer; too narrow would call a live code wrong.
|
|
*/
|
|
const LINK_WINDOW_SEC = 6 * 60
|
|
|
|
/**
|
|
* A link changed, so a clan member's website account changed (D57).
|
|
*
|
|
* Core resolves a Team member's `userId` from the provider's answer, and that
|
|
* answer comes from this table. Without asking, a member who links today is not
|
|
* a member of their clan's Team on the site until core's next scheduled sweep —
|
|
* fifteen minutes by default — which is exactly when a player tries the clan
|
|
* forum for the first time. A request, not a wait: it returns at once and never
|
|
* throws into the link flow.
|
|
*/
|
|
function linksChanged(reason) {
|
|
try {
|
|
core.teams.reconcile({ reason })
|
|
} catch (err) {
|
|
log.warn('could not ask core to reconcile Teams after a link change', { reason, error: err.message })
|
|
}
|
|
}
|
|
|
|
/** 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 })
|
|
linksChanged('rust account linked')
|
|
// Only a NEW link is news. The `already` path above is somebody pressing the
|
|
// button twice, and telling them twice would make the notice meaningless for
|
|
// the one case it exists for: a link they did not make.
|
|
engagement.linked({ userId, steamId, name: frame.name })
|
|
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' }
|
|
|
|
// **Who could hold this code** (PLAN_FIXES F5). The first walk, with five of
|
|
// seven servers down, answered a spent code and a made-up `ZZZZZZ` alike with
|
|
// "one of the servers could not be reached — your code is still good", and
|
|
// would have for as long as any server stayed down. A code lives five minutes
|
|
// on the server that minted it, and every mint is an `account.link.requested`
|
|
// this site has stored — so only a server that minted one recently can hold it,
|
|
// and only one of THOSE being unreachable is a reason to be unsure.
|
|
const recent = new Set(await db.recentLinkIssuers(LINK_WINDOW_SEC))
|
|
const issuers = fleet.filter((server) => recent.has(String(server.id)))
|
|
const others = fleet.filter((server) => !recent.has(String(server.id)))
|
|
|
|
// **In parallel** (F6). One at a time, the walk's redeem waited about four
|
|
// seconds on each dead server in turn — twenty-one in all, the successful link
|
|
// included, because the rig sorted last. The issuers first, since the code is
|
|
// almost always on one of them; the rest only when none of them had it, which
|
|
// covers a code typed in the few seconds before its frame was ingested. Asking
|
|
// a server that does not hold the code costs nothing: it answers `unknown`, and
|
|
// a code is only ever spent where it was minted.
|
|
const asked = await Promise.all(issuers.map((server) => confirmOne({ server, code, userId })))
|
|
const settled = asked.find((result) => result.ok || result.reason === 'taken')
|
|
if (settled) return settled
|
|
|
|
// **Not the ones this site already knows are down** (F6, option b of the step-2
|
|
// walk). In parallel is not enough on its own: while any server is down, a code
|
|
// no issuer holds — every made-up one — still waited out that server's whole
|
|
// timeout, twelve seconds on both rigs. A server the poll has seen go down cannot
|
|
// answer, and it is not an issuer, so it cannot hold the code: it counts as
|
|
// `offline` without the wait. A server with no state yet is asked.
|
|
const down = await knownDown()
|
|
const reachable = others.filter((server) => !down.has(String(server.id)))
|
|
const rest = [
|
|
...(await Promise.all(reachable.map((server) => confirmOne({ server, code, userId })))),
|
|
...others.filter((server) => down.has(String(server.id))).map(() => ({ ok: false, reason: 'offline' })),
|
|
]
|
|
const late = rest.find((result) => result.ok || result.reason === 'taken')
|
|
if (late) return late
|
|
|
|
const every = [...asked, ...rest]
|
|
if (every.every((result) => result.reason === 'offline')) return { ok: false, reason: 'offline' }
|
|
if (asked.some((result) => result.reason === 'offline')) return { ok: false, reason: 'unsure' }
|
|
|
|
return { ok: false, reason: 'rejected' }
|
|
}
|
|
|
|
/**
|
|
* The servers the board poll last saw without a connected game, by id. A failed
|
|
* read is an empty set — everybody is asked, which is slow and never wrong.
|
|
*/
|
|
async function knownDown() {
|
|
try {
|
|
const states = await serversDb.listState()
|
|
return new Set(states.filter((state) => !Number(state.online)).map((state) => String(state.serverId)))
|
|
} catch (err) {
|
|
log.warn('could not read server state for the link fleet', { error: err.message })
|
|
return new Set()
|
|
}
|
|
}
|
|
|
|
/** Remove a link the caller owns. False when they did not hold it. */
|
|
async function unlinkOwned(steamId, userId) {
|
|
const removed = (await db.removeOwned(steamId, userId)) > 0
|
|
if (removed) linksChanged('rust account unlinked')
|
|
return removed
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
const removed = (await db.removeBySteamId(steamId)) > 0
|
|
if (removed) linksChanged('rust account unlinked')
|
|
return removed
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
}
|
|
|
|
// ── Steam's own sign-in as a second way to link (D392, D393, D396) ─────────
|
|
//
|
|
// Core's "Link Steam" (STEAM_SIGNIN.md) proves the account with Steam's OpenID
|
|
// `check_authentication`, which is at least as strong as a code typed in game:
|
|
// so a Steam account linked on the site IS a linked Rust player, beside `/link`.
|
|
// Core asks this module twice through MODULE_API 1.17.0 — before it writes the
|
|
// sign-in identity, and after. Unlinking Steam on the site removes only the
|
|
// sign-in (D393), so there is no third call: the Rust link stays until it is
|
|
// removed on the Rust page or by `/unlink` in game, exactly as one made by code.
|
|
|
|
const STEAM_ID = /^\d{17}$/
|
|
|
|
/**
|
|
* Core's pre-link check (`api.onIdentityLinking`). Refuses a Steam id that is
|
|
* already ANOTHER website account's Rust link (D396) — the same rule as D23 for
|
|
* a code, for the same reason: a link here is a privilege path, and moving it
|
|
* would hand that account's grants to whoever signed in. Core shows "already
|
|
* linked to another account" and nothing is written; staff remove the old link
|
|
* if the claim is real. Our own words go to core's log, never to the page.
|
|
*/
|
|
async function checkSteamSignin({ userId, provider, subject }) {
|
|
if (provider !== 'steam') return { ok: true }
|
|
const held = await db.getBySteamId(String(subject))
|
|
if (held && Number(held.userId) !== Number(userId)) {
|
|
return { ok: false, reason: 'in_use', message: `steam ${subject} is the Rust link of user ${held.userId}` }
|
|
}
|
|
return { ok: true }
|
|
}
|
|
|
|
/**
|
|
* Core's linked hook (`api.onIdentityLinked`): the Steam account becomes this
|
|
* user's Rust link (D392).
|
|
*
|
|
* The same write as `confirmOne`, with the PRIMARY KEY as the race guard: if
|
|
* somebody else's `/link` landed between core's check and this, the key refuses
|
|
* ours and their link stands — the sign-in identity core already wrote is only a
|
|
* way to sign in. `name` is the Steam persona name, which is what a Rust player is
|
|
* called in game until they rename. No `server_id`: no server minted anything.
|
|
*/
|
|
async function linkFromSteamSignin({ userId, provider, subject, displayName }) {
|
|
if (provider !== 'steam') return
|
|
const steamId = String(subject)
|
|
if (!STEAM_ID.test(steamId)) return
|
|
|
|
const held = await db.getBySteamId(steamId)
|
|
if (held) {
|
|
if (Number(held.userId) !== Number(userId)) {
|
|
log.warn('steam sign-in linked an id that is another user\'s Rust link; the Rust link stands', { steamId, userId })
|
|
}
|
|
return
|
|
}
|
|
try {
|
|
await db.insert({ steamId, userId, name: displayName || null, serverId: null })
|
|
} catch (err) {
|
|
if (err && (err.code === 'ER_DUP_ENTRY' || err.errno === 1062)) {
|
|
log.warn('steam sign-in link lost a race for this id; the other link stands', { steamId, userId })
|
|
return
|
|
}
|
|
throw err
|
|
}
|
|
log.info('steam account linked through steam sign-in', { steamId, userId })
|
|
linksChanged('rust account linked via steam sign-in')
|
|
engagement.linked({ userId, steamId, name: displayName || null })
|
|
}
|
|
|
|
/** 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,
|
|
checkSteamSignin,
|
|
linkFromSteamSignin,
|
|
forAdmin,
|
|
}
|