Files
Module-Rust/server/model/links/links.model.js
wtclaude 263be1df45
All checks were successful
PR Checks / client-build (pull_request) Successful in 23s
PR Checks / frozen-manifest (pull_request) Successful in 47s
PR Checks / server-tests (pull_request) Successful in 7m57s
fix(rust): skip servers known down for a link code, hold syncs while offline (F6, F7)
Two findings of the step-2 player walk (2026-09-27, both rigs).

F6, option (b) of the org lead (D186): a code no recent issuer holds -
every made-up one - was still asked of every other enabled server, and
while any of them was down the redeem waited out its whole timeout
(12 s on both rigs). The second pass now skips the servers the board
poll last saw without a connected game; they count as offline without
the wait. Issuers are still asked whatever their state, so a good code
on a down server stays "unsure". Live on the walk core: 338 ms with five
servers down, 360 ms with a rig stopped as well.

F7 (D187): on Carbon a due audit sync went out the moment the sidecar
reconnected, 80 s before "Server startup complete". The worldReady hold
reads the stored hello, which is the OLD boot's until the poll reads the
new one. reasonToSync now also holds while the stored state says the
game is not connected (online 0), which the poll writes the moment the
server goes away. titleSync already held on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-27 03:14:10 -05:00

331 lines
14 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
}
/** 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,
}