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
482 lines
20 KiB
JavaScript
482 lines
20 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. The servers that minted a code recently are asked first, the
|
||
// rest only when none of them had it, each group in parallel (D24, F5, F6) —
|
||
// and "every reachable server said no" is NOT the same answer as "a server
|
||
// that minted a code 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.
|
||
*
|
||
* `issuers` are the servers the site saw mint a link code in the last few
|
||
* minutes (`account.link.requested`, F5) — none by default, which is also what a
|
||
* code typed before its frame was ingested looks like.
|
||
*
|
||
* `down` are the servers the board poll last saw without a connected game
|
||
* (`online: 0`); every other server has no state row, which is asked like an up one.
|
||
*/
|
||
function fleetOf(replies, issuers = [], down = []) {
|
||
const servers = require('../model/servers/servers.model')
|
||
const serversDb = require('../model/servers/servers.db')
|
||
const sidecar = require('../sidecarClient')
|
||
const linksDb = require('../model/links/links.db')
|
||
|
||
const asked = []
|
||
const ids = Object.keys(replies)
|
||
|
||
servers.listForPolling = async () => ids.map((id) => ({ id, baseUrl: `http://${id}`, token: 't' }))
|
||
serversDb.listState = async () => down.map((serverId) => ({ serverId, online: 0 }))
|
||
linksDb.recentLinkIssuers = async () => [...issuers]
|
||
|
||
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('when a server that minted a code answers it, the rest are 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 }, ['a'])
|
||
|
||
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 other game hosts to look up a secret that has already been
|
||
// redeemed.
|
||
assert.deepEqual(asked.map((a) => a.server), ['a'])
|
||
})
|
||
|
||
test('a code typed before its mint was ingested is still found, on a server nobody expected', async () => {
|
||
// Ingest polls every few seconds; a fast typist can beat it. With no recent
|
||
// mint on record the whole fleet is asked, and the code is where it is.
|
||
withCore({ select: [[], [{ steamId: '7656', userId: 4, name: 'Wanderer', serverId: 'c' }]] })
|
||
const links = require('../model/links/links.model')
|
||
|
||
fleetOf({ a: linkRefused, b: unreachable, c: linkOk('7656', 'Wanderer') })
|
||
|
||
const result = await links.redeem({ code: 'K7M2PQ', userId: 4 })
|
||
assert.equal(result.ok, true)
|
||
})
|
||
|
||
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 }, ['a'])
|
||
|
||
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 that minted a code was unreachable"', async () => {
|
||
withCore()
|
||
const links = require('../model/links/links.model')
|
||
|
||
fleetOf({ a: linkRefused, b: unreachable }, ['b'])
|
||
|
||
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)
|
||
})
|
||
|
||
test('a dead server that minted nothing does not make a wrong code "still good" (F5)', async () => {
|
||
// The first walk: five of seven servers down, and a made-up ZZZZZZ was answered
|
||
// "one of the servers could not be reached — your code is still good". A server
|
||
// that has not minted a code in the window cannot hold this one.
|
||
withCore()
|
||
const links = require('../model/links/links.model')
|
||
|
||
fleetOf({ a: linkRefused, b: unreachable, c: unreachable, d: unreachable }, ['a'])
|
||
|
||
assert.equal((await links.redeem({ code: 'ZZZZZZ', userId: 4 })).reason, 'rejected')
|
||
})
|
||
|
||
test('the fleet is asked in parallel, not one dead server after another (F6)', async () => {
|
||
// One at a time, the walk waited about four seconds on each dead server in turn —
|
||
// twenty-one seconds, a successful link included.
|
||
withCore({ select: [[], [{ steamId: '7656', userId: 4, name: 'Wanderer', serverId: 'e' }]] })
|
||
const links = require('../model/links/links.model')
|
||
const sidecar = require('../sidecarClient')
|
||
|
||
fleetOf({ a: unreachable, b: unreachable, c: unreachable, d: unreachable, e: linkOk('7656', 'Wanderer') })
|
||
|
||
let inFlight = 0
|
||
let most = 0
|
||
const scripted = sidecar.confirmLink
|
||
sidecar.confirmLink = async (server, code) => {
|
||
inFlight += 1
|
||
most = Math.max(most, inFlight)
|
||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||
inFlight -= 1
|
||
return scripted(server, code)
|
||
}
|
||
|
||
const result = await links.redeem({ code: 'K7M2PQ', userId: 4 })
|
||
assert.equal(result.ok, true)
|
||
assert.equal(most, 5, 'every server was being asked at once')
|
||
})
|
||
|
||
test('a server the poll saw go down is not waited on for a code nobody minted (F6, option b)', async () => {
|
||
// The step-2 walk: with one rig stopped, a made-up code took twelve seconds on
|
||
// both frameworks — in parallel, but still one whole timeout on the dead server.
|
||
withCore()
|
||
const links = require('../model/links/links.model')
|
||
|
||
const asked = fleetOf({ a: linkRefused, b: unreachable, c: linkRefused }, [], ['b'])
|
||
|
||
assert.equal((await links.redeem({ code: 'ZZZZZZ', userId: 4 })).reason, 'rejected')
|
||
assert.deepEqual(asked.map((x) => x.server).sort(), ['a', 'c'], 'the server known down was not asked')
|
||
})
|
||
|
||
test('an issuer is asked even when the poll saw it go down, so a good code stays "unsure"', async () => {
|
||
// Skipping is for the servers that cannot hold the code. A server that minted
|
||
// one in the window might — and a player whose code is on it must not be told
|
||
// the code is wrong.
|
||
withCore()
|
||
const links = require('../model/links/links.model')
|
||
|
||
const asked = fleetOf({ a: unreachable, b: linkRefused }, ['a'], ['a'])
|
||
|
||
assert.equal((await links.redeem({ code: 'K7M2PQ', userId: 4 })).reason, 'unsure')
|
||
assert.ok(asked.some((x) => x.server === 'a'), 'the down issuer was still asked')
|
||
})
|
||
|
||
test('a fleet the poll saw all go down is offline, without asking any of it', async () => {
|
||
withCore()
|
||
const links = require('../model/links/links.model')
|
||
|
||
const asked = fleetOf({ a: unreachable, b: unreachable }, [], ['a', 'b'])
|
||
|
||
assert.equal((await links.redeem({ code: 'ZZZZZZ', userId: 4 })).reason, 'offline')
|
||
assert.equal(asked.length, 0)
|
||
})
|
||
|
||
test('a state read that fails asks everybody, which is slow and never wrong', async () => {
|
||
withCore()
|
||
const links = require('../model/links/links.model')
|
||
const serversDb = require('../model/servers/servers.db')
|
||
|
||
const asked = fleetOf({ a: linkRefused, b: linkRefused })
|
||
serversDb.listState = async () => {
|
||
throw new Error('pool closed')
|
||
}
|
||
|
||
assert.equal((await links.redeem({ code: 'ZZZZZZ', userId: 4 })).reason, 'rejected')
|
||
assert.equal(asked.length, 2)
|
||
})
|
||
|
||
|
||
// ── Steam's own sign-in as a second way to link (D392, D393, D396) ─────────
|
||
|
||
const STEAM = '76561197960287930'
|
||
|
||
test('core’s pre-link check refuses a Steam id that is another account’s Rust link (D396)', async () => {
|
||
withCore({ select: [[{ steamId: STEAM, userId: 9, username: 'bob' }]] })
|
||
const links = require('../model/links/links.model')
|
||
const answer = await links.checkSteamSignin({ userId: 4, provider: 'steam', subject: STEAM })
|
||
assert.equal(answer.ok, false)
|
||
assert.equal(answer.reason, 'in_use')
|
||
})
|
||
|
||
test('the pre-link check passes a free Steam id, one already yours, and every other provider', async () => {
|
||
withCore({ select: [[], [{ steamId: STEAM, userId: 4 }]] })
|
||
const links = require('../model/links/links.model')
|
||
assert.deepEqual(await links.checkSteamSignin({ userId: 4, provider: 'steam', subject: STEAM }), { ok: true })
|
||
assert.deepEqual(await links.checkSteamSignin({ userId: 4, provider: 'steam', subject: STEAM }), { ok: true })
|
||
assert.deepEqual(await links.checkSteamSignin({ userId: 4, provider: 'google', subject: 'x' }), { ok: true })
|
||
})
|
||
|
||
test('a Steam account linked on the site becomes a linked Rust player (D392)', async () => {
|
||
let inserted = null
|
||
const { ctx } = withCore({ select: [[]], onInsert: (params) => { inserted = params; return Promise.resolve({ affectedRows: 1 }) } })
|
||
const links = require('../model/links/links.model')
|
||
await links.linkFromSteamSignin({ userId: 4, provider: 'steam', subject: STEAM, displayName: 'CoolGuy99' })
|
||
assert.deepEqual(inserted, [STEAM, 4, 'CoolGuy99', null])
|
||
assert.equal(ctx.teams.reconcile.calls.length, 1)
|
||
assert.equal(ctx.events.emit.calls.length, 1, 'the player hears about the new link, as with /link')
|
||
})
|
||
|
||
test('the linked hook writes nothing for a link that is already there, or for another provider', async () => {
|
||
let inserts = 0
|
||
const { ctx } = withCore({ select: [[{ steamId: STEAM, userId: 4 }]], onInsert: () => { inserts += 1; return Promise.resolve({}) } })
|
||
const links = require('../model/links/links.model')
|
||
await links.linkFromSteamSignin({ userId: 4, provider: 'steam', subject: STEAM, displayName: 'x' })
|
||
await links.linkFromSteamSignin({ userId: 4, provider: 'discord', subject: '123', displayName: 'x' })
|
||
await links.linkFromSteamSignin({ userId: 4, provider: 'steam', subject: 'not-an-id', displayName: 'x' })
|
||
assert.equal(inserts, 0)
|
||
assert.equal(ctx.teams.reconcile.calls.length, 0)
|
||
})
|
||
|
||
test('a /link that lands between core’s check and the hook keeps its link; the hook does not throw', async () => {
|
||
const dup = Object.assign(new Error('dup'), { code: 'ER_DUP_ENTRY' })
|
||
const { ctx } = withCore({ select: [[]], onInsert: () => Promise.reject(dup) })
|
||
const links = require('../model/links/links.model')
|
||
await links.linkFromSteamSignin({ userId: 4, provider: 'steam', subject: STEAM, displayName: 'x' })
|
||
assert.equal(ctx.teams.reconcile.calls.length, 0)
|
||
})
|