feat(rust): identity — a link code from the game, and the Steam id inside core's user page
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
This commit is contained in:
@@ -28,7 +28,7 @@ test('an unknown kind is not public — the default is deny', () => {
|
||||
assert.equal(catalogue.isPublic('player.location'), false)
|
||||
})
|
||||
|
||||
test('nothing carrying an IP address or a report is public', () => {
|
||||
test('nothing carrying an IP address, a report or an identity is public', () => {
|
||||
for (const kind of [
|
||||
'player.login.attempt',
|
||||
'player.approved',
|
||||
@@ -36,6 +36,11 @@ test('nothing carrying an IP address or a report is public', () => {
|
||||
'player.unbanned',
|
||||
'player.reported',
|
||||
'entity.destroyed',
|
||||
// Protocol 3. A link request on a public killfeed would tell everyone which
|
||||
// Steam id is about to become a named website account, and an unlink would
|
||||
// say when somebody stopped being one.
|
||||
'account.link.requested',
|
||||
'account.unlinked',
|
||||
]) {
|
||||
assert.equal(catalogue.isPublic(kind), false, `${kind} must not be public`)
|
||||
assert.ok(catalogue.STAFF_KINDS.includes(kind), `${kind} must be classified, not merely absent`)
|
||||
@@ -82,13 +87,13 @@ test('every kind is classified exactly once', () => {
|
||||
assert.equal(seen.size, catalogue.PUBLIC_KINDS.length + catalogue.STAFF_KINDS.length)
|
||||
})
|
||||
|
||||
test('the classification covers exactly the kinds protocol 2 defines', () => {
|
||||
test('the classification covers exactly the kinds protocol 3 defines', () => {
|
||||
// The spec lives in another repository, so the list is restated here rather
|
||||
// than parsed — and restating it is the point: adding a kind to the protocol
|
||||
// without deciding who may see it has to fail somewhere, and this is where.
|
||||
//
|
||||
// Sourced from docs/rust-link/PROTOCOL.md §8.4.
|
||||
const PROTOCOL_2 = [
|
||||
const PROTOCOL_3 = [
|
||||
'player.connected',
|
||||
'player.disconnected',
|
||||
'player.respawned',
|
||||
@@ -104,7 +109,9 @@ test('the classification covers exactly the kinds protocol 2 defines', () => {
|
||||
'server.wipe',
|
||||
'server.initialized',
|
||||
'server.shutdown',
|
||||
'account.link.requested',
|
||||
'account.unlinked',
|
||||
]
|
||||
|
||||
assert.deepEqual([...catalogue.ALL_KINDS].sort(), [...PROTOCOL_2].sort())
|
||||
assert.deepEqual([...catalogue.ALL_KINDS].sort(), [...PROTOCOL_3].sort())
|
||||
})
|
||||
|
||||
82
server/test/identityRoutes.test.js
Normal file
82
server/test/identityRoutes.test.js
Normal file
@@ -0,0 +1,82 @@
|
||||
// ── The shape of the identity surface ─────────────────────────────────────
|
||||
//
|
||||
// Three properties that are invisible in review and expensive in production:
|
||||
//
|
||||
// • **the link route is rate-limited** (R1). Six characters from a 32-glyph
|
||||
// alphabet is a good code only while a guesser is made to pay per attempt,
|
||||
// and once phase 7 grants permissions against a link, guessing one is a
|
||||
// privilege-escalation path rather than a nuisance.
|
||||
// • **the extension router merges its parent's params**. Without
|
||||
// `mergeParams`, `req.params.id` is `undefined` and every statement in that
|
||||
// panel silently scopes to no user — a panel that reads as "this user has no
|
||||
// Rust account" for everybody.
|
||||
// • **the extension's paths keep the module's own segment.** Core owns
|
||||
// `/admin/users/:id`; a bare `/links` would be this module claiming a word on
|
||||
// a URL it does not own, and the next module to fill a slot would collide.
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
|
||||
const { fakeCtx, fakeApi } = require('./_fakes')
|
||||
|
||||
function register(ctx = fakeCtx()) {
|
||||
require('../core')._reset()
|
||||
const api = fakeApi()
|
||||
require('../index')(ctx, api)
|
||||
return api
|
||||
}
|
||||
|
||||
/** `[{ method, path, handlers }]` for one express router. */
|
||||
function routesOf(router) {
|
||||
return router.stack
|
||||
.filter((layer) => layer.route)
|
||||
.map((layer) => ({
|
||||
path: layer.route.path,
|
||||
method: Object.keys(layer.route.methods)[0].toUpperCase(),
|
||||
handlers: layer.route.stack.map((s) => s.handle),
|
||||
}))
|
||||
}
|
||||
|
||||
test('the player tier serves the three identity routes, and nothing else new', () => {
|
||||
const api = register()
|
||||
const routes = routesOf(api.record.routes.player['/rust'])
|
||||
|
||||
assert.deepEqual(
|
||||
routes.map((r) => `${r.method} ${r.path}`).sort(),
|
||||
['DELETE /links/:steamId', 'GET /links', 'GET /servers', 'POST /link'],
|
||||
)
|
||||
})
|
||||
|
||||
test('redeeming a code is rate-limited, and by a limiter of its own', () => {
|
||||
const api = register()
|
||||
const post = routesOf(api.record.routes.player['/rust']).find((r) => r.method === 'POST')
|
||||
|
||||
// The fake's `rateLimit` hands back a pass-through carrying the options it was
|
||||
// given, so the policy itself is assertable — a limiter that was quietly
|
||||
// removed, or one built with core's `accountChangeLimiter` shared counter,
|
||||
// both fail here.
|
||||
const limiter = post.handlers.find((h) => h.options && h.options.label === 'rust-link-code')
|
||||
|
||||
assert.ok(limiter, 'POST /link must carry its own rate limiter (R1)')
|
||||
assert.equal(limiter.options.max, 10)
|
||||
assert.equal(limiter.options.windowMs, 15 * 60 * 1000)
|
||||
|
||||
// First in the chain: a limiter behind the validator would let an attacker
|
||||
// spend the cheap half of the request unbounded.
|
||||
assert.equal(post.handlers[0], limiter)
|
||||
})
|
||||
|
||||
test('the admin.users.detail router merges the parent’s params and keeps its own segment', () => {
|
||||
const api = register()
|
||||
const slot = api.record.extensions.find((e) => e.slot === 'admin.users.detail')
|
||||
|
||||
assert.ok(slot, 'the server half of admin.users.detail must be registered')
|
||||
assert.equal(slot.router.mergeParams, true)
|
||||
|
||||
const paths = routesOf(slot.router).map((r) => `${r.method} ${r.path}`).sort()
|
||||
assert.deepEqual(paths, ['DELETE /rust/links/:steamId', 'GET /rust/links'])
|
||||
|
||||
for (const route of routesOf(slot.router)) {
|
||||
assert.ok(route.path.startsWith('/rust/'), `${route.path} must live under this module's own segment`)
|
||||
}
|
||||
})
|
||||
@@ -327,3 +327,35 @@ test('a board replaces presence rather than appending to it', async () => {
|
||||
assert.match(presence[0].sql, /^DELETE FROM rust_presence/)
|
||||
assert.match(presence[1].sql, /INSERT INTO rust_presence/)
|
||||
})
|
||||
|
||||
// ── Protocol 3: the frame that changes something other than a counter ─────
|
||||
|
||||
test('an in-game /unlink severs the site link, scoped by Steam id alone', async () => {
|
||||
const rec = withRecorder()
|
||||
const ingest = require('../ingest')
|
||||
|
||||
await ingest.apply('main', item('account.unlinked', { steamId: '7656', name: 'Wanderer', origin: 'in-game' }))
|
||||
|
||||
const del = rec.statements.find((st) => st.sql.trim().toUpperCase().startsWith('DELETE'))
|
||||
|
||||
// It arrives on the FEED rather than through a route because the plugin has no
|
||||
// link to delete — the site is the author of record. And it is the only way out
|
||||
// of a link on the wrong account, because the site refuses to move a Steam id
|
||||
// another account already holds (D23).
|
||||
assert.ok(del, 'an unlink frame must delete the link')
|
||||
assert.ok(del.sql.includes('rust_account_links'))
|
||||
assert.deepEqual(del.params, ['7656'])
|
||||
})
|
||||
|
||||
test('asking for a code links nothing — the code does not travel on the wire', async () => {
|
||||
const rec = withRecorder()
|
||||
const ingest = require('../ingest')
|
||||
|
||||
await ingest.apply('main', item('account.link.requested', { steamId: '7656', name: 'Wanderer', ttlSec: 300 }))
|
||||
|
||||
// The frame exists so an operator can see linking being used. Nothing about it
|
||||
// is redeemable: the code travels through the player, which is what makes
|
||||
// typing it proof that they are the one who asked.
|
||||
assert.equal(rec.touching('rust_account_links').length, 0)
|
||||
assert.equal(rec.touching('rust_players').length, 1)
|
||||
})
|
||||
|
||||
276
server/test/links.test.js
Normal file
276
server/test/links.test.js
Normal file
@@ -0,0 +1,276 @@
|
||||
// ── 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'])
|
||||
})
|
||||
Reference in New Issue
Block a user