R1's identity link, site-side, and R13's first extension slot. A player types /link in game, the plugin hands them a six-character code privately, and they enter it here; the site records who owns which Steam account, and an operator sees that on core's own `/admin/users/:id` page. **The site is the author of record and the game holds nothing.** There is no per-account store in Rust that survives a wipe, and phase 7 needs the site 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. ## D24 — a code is minted by ONE server, so every server is asked Nothing in six characters says where it came from. The fleet is asked in turn and the first `link.ok` wins; the others answer `unknown` and nothing happens there, because a code is only spent at the server that actually holds it. Asking the player to pick was rejected: a wrong pick would come back indistinguishable from a wrong code, and that is the one refusal which must not be ambiguous. **"Every reachable server refused" is not the same answer as "a server was unreachable."** Collapsing them tells a player whose server is down that their code is wrong — so they run /link again on that same server and are told the same thing for as long as it stays down. `unsure` is that case, and it says to try again rather than to fetch a new code. ## D23 — a Steam id another account holds is refused, never moved The primary key is `steam_id`, and it is load-bearing rather than tidy: phase 7 grants permissions against a link and phase 13 hangs entitlements off it, so a silent move is an account takeover performed by typing six characters. The refusal names the holder, because the advice is unusable without it. The INSERT is a plain INSERT for the same reason — `ON DUPLICATE KEY UPDATE` here would BE that move — and the duplicate-key error is the refusal for the race the check above cannot close. The way out is `/unlink` in game, which reaches the site off the ingest feed rather than through a route (the plugin has no link to delete). D25 adds the other way out: staff can sever a link from the admin panel, for a player who cannot reach that Steam account in game. ## The slot, and the hole it found in this repo's own generator `admin.users.detail` is declared in `module.json` AND registered in `index.js` AND filled by the chunk — three places, because the server half and the client half are different registrations that share one name. `swaggerFragment.js` knew only about tier routers, so the two routes under `/admin/users/:id` were generated by nothing: a fragment that was internally consistent and described two routes fewer than the module serves. A slot's mount is core's and cannot be derived here, so it is a fourth constant beside `TIER_BASE` — held to account by the frozen-manifest job, which was verified to catch exactly this by removing the two paths and watching it fail. ## Smaller things worth knowing - **Core's `useAsync` has no `refresh`.** A counter in the deps is how a page re-reads after its own write; it blanks while it re-reads, which is right here and is exactly what made it wrong for a poll. - **Every player-portal nav row needs an `icon`** — core draws one on every row, and the client suite says so. This module had no icons file until now, because the public header is text buttons. - The two new frame kinds are STAFF-only. Neither carries a code, but both name a Steam id beside a website account's activity, and that join is not a public fact about what happened on a server. - The link code route carries its own rate limiter rather than core's `accountChangeLimiter`: this is guessing somebody else's secret, not changing your own password, and a shared counter would let one policy set the other. Protocol 3 on all three declaration sites; 17 new tests, 136 green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
277 lines
10 KiB
JavaScript
277 lines
10 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'])
|
|
})
|