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
320 lines
12 KiB
JavaScript
320 lines
12 KiB
JavaScript
// ── Identity: the fleet loop and the refusal ──────────────────────────────
|
|
//
|
|
// Two things in this file are worth more than the rest, and both are about
|
|
// telling answers apart that a naive implementation collapses:
|
|
//
|
|
// • **A code is minted by ONE server** and the player types six characters into
|
|
// a browser. Every server is asked in turn (D24), and "every reachable server
|
|
// said no" is NOT the same answer as "a server could not be reached" — the
|
|
// second is the case where the player's code is perfectly good and the advice
|
|
// "run /link again" is useless, because it sends them back to the server that
|
|
// is down.
|
|
//
|
|
// • **A Steam id another account holds is refused, never moved** (D23). Once
|
|
// phase 7 grants permissions against a link and phase 13 hangs entitlements
|
|
// off it, a silent move is an account takeover performed by typing six
|
|
// characters.
|
|
|
|
const test = require('node:test')
|
|
const assert = require('node:assert')
|
|
|
|
const { fakeCtx } = require('./_fakes')
|
|
|
|
/**
|
|
* Installs a ctx whose `db.query` answers from a small script.
|
|
*
|
|
* `rows` is consulted by the first word of the statement, which is as much SQL as
|
|
* these tests should know: the point of each one is the decision the model makes,
|
|
* not the shape of a SELECT it delegates.
|
|
*/
|
|
function withCore({ select = [], onInsert = null } = {}) {
|
|
const queries = []
|
|
|
|
const ctx = fakeCtx({
|
|
db: {
|
|
query: (sql, params) => {
|
|
queries.push({ sql, params })
|
|
|
|
const verb = sql.trim().split(/\s+/)[0].toUpperCase()
|
|
|
|
if (verb === 'SELECT') {
|
|
const next = Array.isArray(select) ? select.shift() : select
|
|
return Promise.resolve(next || [])
|
|
}
|
|
|
|
if (verb === 'INSERT' && onInsert) return onInsert(params)
|
|
|
|
return Promise.resolve({ affectedRows: 1 })
|
|
},
|
|
pool: {},
|
|
},
|
|
})
|
|
|
|
require('../core')._reset()
|
|
require('../core').init(ctx)
|
|
|
|
return { ctx, queries }
|
|
}
|
|
|
|
/** A fleet of `n` servers, and a sidecar that answers from a script. */
|
|
function fleetOf(replies) {
|
|
const servers = require('../model/servers/servers.model')
|
|
const sidecar = require('../sidecarClient')
|
|
|
|
const asked = []
|
|
const ids = Object.keys(replies)
|
|
|
|
servers.listForPolling = async () => ids.map((id) => ({ id, baseUrl: `http://${id}`, token: 't' }))
|
|
|
|
sidecar.confirmLink = async (server, code) => {
|
|
asked.push({ server: server.id, code })
|
|
return replies[server.id]
|
|
}
|
|
|
|
return asked
|
|
}
|
|
|
|
/** The two replies a reachable sidecar can carry, and the one it cannot. */
|
|
const linkOk = (steamId, name) => ({ ok: true, status: 'ok', data: { kind: 'link.ok', steamId, name } })
|
|
const linkRefused = { ok: true, status: 'ok', data: { kind: 'link.error', reason: 'unknown' } }
|
|
const unreachable = { ok: false, status: 'transport-error', data: null }
|
|
|
|
test('every server is asked until one recognises the code, and the one that answered is recorded', async () => {
|
|
const { queries } = withCore({ select: [[], [{ steamId: '7656', userId: 4, name: 'Wanderer', serverId: 'b' }]] })
|
|
const links = require('../model/links/links.model')
|
|
|
|
const asked = fleetOf({ a: linkRefused, b: linkOk('7656', 'Wanderer') })
|
|
|
|
const result = await links.redeem({ code: 'K7M2PQ', userId: 4 })
|
|
|
|
assert.equal(result.ok, true)
|
|
assert.equal(result.link.steamId, '7656')
|
|
|
|
// Both servers were asked, in order, with the same code — and the loop stopped
|
|
// at the one that said yes.
|
|
assert.deepEqual(asked, [{ server: 'a', code: 'K7M2PQ' }, { server: 'b', code: 'K7M2PQ' }])
|
|
|
|
// The server that minted it is stored. It is not part of the identity — a link
|
|
// is fleet-wide — but it is where a support conversation starts.
|
|
const insert = queries.find((q) => q.sql.trim().toUpperCase().startsWith('INSERT'))
|
|
assert.deepEqual(insert.params, ['7656', 4, 'Wanderer', 'b'])
|
|
})
|
|
|
|
test('a server after the one that answered is never asked', async () => {
|
|
withCore({ select: [[], [{ steamId: '7656', userId: 4 }]] })
|
|
const links = require('../model/links/links.model')
|
|
|
|
const asked = fleetOf({ a: linkOk('7656', 'Wanderer'), b: linkRefused, c: linkRefused })
|
|
|
|
await links.redeem({ code: 'K7M2PQ', userId: 4 })
|
|
|
|
// A code is spent on the plugin's FIRST lookup, so carrying on after a yes
|
|
// would be asking four other game hosts to look up a secret that has already
|
|
// been redeemed.
|
|
assert.deepEqual(asked.map((a) => a.server), ['a'])
|
|
})
|
|
|
|
test('a Steam id another account holds is refused, not moved — and the loop stops', async () => {
|
|
// The whole of D23 in one assertion. The holder is named because the player is
|
|
// signed in and the advice ("sign in as that account, or run /unlink") is
|
|
// unusable without it.
|
|
withCore({ select: [[{ steamId: '7656', userId: 9, username: 'someone-else' }]] })
|
|
const links = require('../model/links/links.model')
|
|
|
|
const asked = fleetOf({ a: linkOk('7656', 'Wanderer'), b: linkRefused })
|
|
|
|
const result = await links.redeem({ code: 'K7M2PQ', userId: 4 })
|
|
|
|
assert.equal(result.ok, false)
|
|
assert.equal(result.reason, 'taken')
|
|
assert.equal(result.username, 'someone-else')
|
|
|
|
// Asking the rest of the fleet would answer the same question more slowly: the
|
|
// verdict is about the Steam id, not about this server.
|
|
assert.deepEqual(asked.map((a) => a.server), ['a'])
|
|
})
|
|
|
|
test('a code already redeemed by the SAME user is a success, not an error', async () => {
|
|
withCore({ select: [[{ steamId: '7656', userId: 4, name: 'Wanderer', serverId: 'a' }]] })
|
|
const links = require('../model/links/links.model')
|
|
|
|
fleetOf({ a: linkOk('7656', 'Wanderer') })
|
|
|
|
const result = await links.redeem({ code: 'K7M2PQ', userId: 4 })
|
|
|
|
// A player who pressed the button twice, or whose confirmation was applied on a
|
|
// request that then timed out. Reporting that as a failure would send them to
|
|
// run `/link` again for a link they already have.
|
|
assert.equal(result.ok, true)
|
|
assert.equal(result.already, true)
|
|
})
|
|
|
|
test('"every reachable server refused" is not the same answer as "a server was unreachable"', async () => {
|
|
withCore()
|
|
const links = require('../model/links/links.model')
|
|
|
|
fleetOf({ a: linkRefused, b: unreachable })
|
|
|
|
const result = await links.redeem({ code: 'K7M2PQ', userId: 4 })
|
|
|
|
// The failure this prevents: a player linked on the server that is down, is
|
|
// told their code is wrong, runs `/link` again on that same server, and is told
|
|
// the same thing for as long as it stays down.
|
|
assert.equal(result.reason, 'unsure')
|
|
})
|
|
|
|
test('a fleet nobody can reach is offline, and a fleet that all refused is a bad code', async () => {
|
|
withCore()
|
|
let links = require('../model/links/links.model')
|
|
|
|
fleetOf({ a: unreachable, b: unreachable })
|
|
assert.equal((await links.redeem({ code: 'K7M2PQ', userId: 4 })).reason, 'offline')
|
|
|
|
withCore()
|
|
links = require('../model/links/links.model')
|
|
|
|
fleetOf({ a: linkRefused, b: linkRefused })
|
|
assert.equal((await links.redeem({ code: 'K7M2PQ', userId: 4 })).reason, 'rejected')
|
|
})
|
|
|
|
test('a site with no servers configured says so rather than that the code is wrong', async () => {
|
|
withCore()
|
|
const links = require('../model/links/links.model')
|
|
|
|
fleetOf({})
|
|
|
|
assert.equal((await links.redeem({ code: 'K7M2PQ', userId: 4 })).reason, 'no-servers')
|
|
})
|
|
|
|
test('two confirmations of one Steam id race into the primary key, not into a 500', async () => {
|
|
// The window the PRIMARY KEY exists for: both requests read "not linked", both
|
|
// write. The second insert is refused by the key, and the refusal has to become
|
|
// the same sentence the check above produces — otherwise one of two players
|
|
// pressing a button at the same moment gets an internal error.
|
|
const dup = Object.assign(new Error('duplicate'), { code: 'ER_DUP_ENTRY' })
|
|
|
|
withCore({
|
|
select: [[], [{ steamId: '7656', userId: 9, username: 'someone-else' }]],
|
|
onInsert: () => Promise.reject(dup),
|
|
})
|
|
const links = require('../model/links/links.model')
|
|
|
|
fleetOf({ a: linkOk('7656', 'Wanderer') })
|
|
|
|
const result = await links.redeem({ code: 'K7M2PQ', userId: 4 })
|
|
|
|
assert.equal(result.ok, false)
|
|
assert.equal(result.reason, 'taken')
|
|
assert.equal(result.username, 'someone-else')
|
|
})
|
|
|
|
test('the same race, won by the caller, is a success', async () => {
|
|
const dup = Object.assign(new Error('duplicate'), { errno: 1062 })
|
|
|
|
withCore({
|
|
select: [[], [{ steamId: '7656', userId: 4, name: 'Wanderer', serverId: 'a' }]],
|
|
onInsert: () => Promise.reject(dup),
|
|
})
|
|
const links = require('../model/links/links.model')
|
|
|
|
fleetOf({ a: linkOk('7656', 'Wanderer') })
|
|
|
|
const result = await links.redeem({ code: 'K7M2PQ', userId: 4 })
|
|
|
|
assert.equal(result.ok, true)
|
|
assert.equal(result.already, true)
|
|
})
|
|
|
|
test('a link is never shaped with anything a code could be recovered from', async () => {
|
|
withCore()
|
|
const links = require('../model/links/links.model')
|
|
|
|
const shaped = links.shape({
|
|
steamId: '7656',
|
|
userId: 4,
|
|
username: 'someone',
|
|
name: 'Wanderer',
|
|
serverId: 'a',
|
|
linkedAt: '2026-09-21T00:00:00Z',
|
|
})
|
|
|
|
// `userId` and `username` are deliberately absent: the caller is the user, and
|
|
// a list that carried somebody's website username would be a different fact
|
|
// from "you hold this Steam id".
|
|
assert.deepEqual(Object.keys(shaped).sort(), ['linkedAt', 'name', 'serverId', 'steamId'])
|
|
})
|
|
|
|
test('an unlink is scoped by user in the statement, not checked before it', async () => {
|
|
const { queries } = withCore()
|
|
const links = require('../model/links/links.model')
|
|
|
|
await links.unlinkOwned('7656', 4)
|
|
|
|
const del = queries.find((q) => q.sql.trim().toUpperCase().startsWith('DELETE'))
|
|
|
|
// Read-then-write would leave a gap between the ownership test and the
|
|
// deletion; one statement closes it, and the row count is what tells "removed"
|
|
// from "was not yours".
|
|
assert.ok(del.sql.includes('user_id = ?'))
|
|
assert.deepEqual(del.params, ['7656', 4])
|
|
})
|
|
|
|
test('the in-game unlink is scoped by Steam id alone, because that is the authority', async () => {
|
|
const { queries } = withCore()
|
|
const links = require('../model/links/links.model')
|
|
|
|
await links.unlinkFromGame('7656')
|
|
|
|
const del = queries.find((q) => q.sql.trim().toUpperCase().startsWith('DELETE'))
|
|
|
|
// Whoever is connected to the game as that Steam account is who it is — a
|
|
// stronger proof of ownership than the site can obtain any other way. Scoping
|
|
// this by website user would make `/unlink` fail for the one player who needs
|
|
// it: the one who linked the wrong account.
|
|
assert.ok(!del.sql.includes('user_id'))
|
|
assert.deepEqual(del.params, ['7656'])
|
|
})
|
|
|
|
test('a player sees the name the GAME last saw, not the one they linked under', async () => {
|
|
withCore({ select: [[{ steamId: '7656', userId: 4, name: 'Wanderer-old', playerName: 'Wanderer', serverId: 'a', linkedAt: 'x' }]] })
|
|
const links = require('../model/links/links.model')
|
|
|
|
const [link] = await links.listForUser(4)
|
|
|
|
// Found in a browser: staff saw `Wanderer` on the admin panel and the player
|
|
// saw `Wanderer-old` on their own page — the same person, labelled two ways on
|
|
// one site, because a Rust name changes on a whim and only one of the two reads
|
|
// was joining `rust_players`.
|
|
assert.equal(link.name, 'Wanderer')
|
|
|
|
// And the fallback still holds for a link whose account has never played.
|
|
withCore({ select: [[{ steamId: '7656', userId: 4, name: 'Wanderer-old', playerName: null, linkedAt: 'x' }]] })
|
|
const again = require('../model/links/links.model')
|
|
assert.equal((await again.listForUser(4))[0].name, 'Wanderer-old')
|
|
})
|
|
|
|
test('a new link and a removed one ask core to reconcile Teams (D57)', async () => {
|
|
// A clan member's website account comes from this table. Without the request,
|
|
// somebody who links today is not in their clan's Team until core's next
|
|
// scheduled sweep.
|
|
const { ctx } = withCore({ select: [[], [{ steamId: '7656', userId: 4 }]] })
|
|
const links = require('../model/links/links.model')
|
|
fleetOf({ a: linkOk('7656', 'Wanderer') })
|
|
|
|
await links.redeem({ code: 'K7M2PQ', userId: 4 })
|
|
assert.equal(ctx.teams.reconcile.calls.length, 1)
|
|
|
|
await links.unlinkAnyOwner('7656')
|
|
assert.equal(ctx.teams.reconcile.calls.length, 2)
|
|
})
|
|
|
|
test('a link that was already there asks for nothing', async () => {
|
|
const { ctx } = withCore({ select: [[{ steamId: '7656', userId: 4 }]] })
|
|
const links = require('../model/links/links.model')
|
|
fleetOf({ a: linkOk('7656', 'Wanderer') })
|
|
|
|
const result = await links.redeem({ code: 'K7M2PQ', userId: 4 })
|
|
assert.equal(result.already, true)
|
|
assert.equal(ctx.teams.reconcile.calls.length, 0)
|
|
})
|