A first-party Rust clan is a Team (R5). This module becomes the site's Team provider and answers core from the plugin's `clans` board. Design of record: docs/modules/rust/PLAN.md §24, D47-D58. - The store: rust_clans, rust_clan_members and rust_clan_boards. A clan's identity is <serverId>:<clanId>:<createdMs> (D52), because the game restarts clan ids whenever its clan database version changes. - The provider (D53): getTeams is complete only when every server's board is fresh, supported and untruncated. It is partial when some are, and refuses when none are. Freshness is judged by the website's clock, from when the board's `t` last advanced. - Only a complete board may mark a clan gone. A board at the game's 100-clan ceiling (D55), or one with an unreadable row, proves nothing about what it leaves out. - Leadership is diffed board to board and published (D54). The five clan events are published as team.* kinds, and written to the Team feed as members-only lines (D49). - Core only writes feed items for a Team it already holds. So the last 10 minutes of clan events are re-offered on each board refresh, deduped by a sha1 key: core clamps a dedupeKey to 40 characters, and a readable key would be truncated into collisions. - projectRoster and the clan page share one audience rule (D48): the clan's linked members and staff by default, re-read from the users row. The setting lives on Admin > Rust visibility, which also warns about uMod Clans (D47) and the ceiling. - Public: GET servers/:id/clans (the list is public, D58) and GET clans/:externalId. The client adds a Clans tab and /rust/clans/:externalId, with three module slots for core's notify, activity and forum contributions (D56). - Linking and unlinking an account ask core to reconcile Teams (D57). - The clan kinds are staff-class in the public feed allowlist. - PROTOCOL_VERSION is now 6. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
282 lines
11 KiB
JavaScript
282 lines
11 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 servers = require('../servers/servers.model')
|
|
const sidecar = require('../../sidecarClient')
|
|
|
|
const log = core.logger('links')
|
|
|
|
/**
|
|
* 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')
|
|
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) {
|
|
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,
|
|
}
|