feat(links): unlink every Steam id before an account is deleted (MODULE_API 1.20.0)
All checks were successful
PR Checks / server-tests (pull_request) Successful in 1m1s
PR Checks / frozen-manifest (pull_request) Successful in 1m28s
PR Checks / client-build (pull_request) Successful in 43s

ACCOUNT_DELETION.md §4.4 (AD10). api.onUserDeleting calls links.unlinkForDeletion,
which runs the player's own unlinkOwned per link, logs rust.account.unlink with
reason 'account deleted', and marks every server's permission sync dirty so the
withdrawn grants reach the game on the next tick. coreApi ^1.20.0; core-ref pinned
to website#232's merge (7bd93b8); engagement manifest regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-29 20:38:30 -05:00
parent ec90d2d5ae
commit b5573d9ae3
10 changed files with 104 additions and 6 deletions

View File

@@ -168,6 +168,13 @@ module.exports = function register(ctx, api) {
api.onIdentityLinking(links.checkSteamSignin)
api.onIdentityLinked(links.linkFromSteamSignin)
// Account deletion (MODULE_API 1.20.0; ACCOUNT_DELETION.md §4.4, AD10). Core
// asks before the users row goes: every Steam id the account holds is unlinked
// through the player's own unlink, and the permission sync is marked dirty so
// the withdrawn grants reach the game on the next tick. It cannot stop the
// deletion, and never waits on a game.
api.onUserDeleting(links.unlinkForDeletion)
// The public demo's seed (MODULE_API 1.18.0, DM15): the rig's server row and
// demo-player's Steam link, written by this module from its part of the
// fixture. Called only by `npm run seed:demo` on a DEMO_MODE deployment. The

View File

@@ -19,6 +19,7 @@
const core = require('../../core')
const db = require('./links.db')
const engagement = require('../../engagement/emit')
const permissionsDb = require('../permissions/permissions.db')
const servers = require('../servers/servers.model')
const serversDb = require('../servers/servers.db')
const sidecar = require('../../sidecarClient')
@@ -351,6 +352,47 @@ async function linkFromSteamSignin({ userId, provider, subject, displayName }) {
engagement.linked({ userId, steamId, name: displayName || null })
}
// ── The website account is being deleted (MODULE_API 1.20.0) ──────────────
//
// ACCOUNT_DELETION.md §4.4. Core asks before the users row goes, while the
// person's links can still be read (AD10). The cascade on `rust_account_links`
// would remove the rows anyway; calling the real unlink keeps what a cascade
// skips — the `rust.account.unlink` entry staff read and the prompt Teams
// re-sync — so the game sees exactly what a player's own unlink produces.
/**
* Core's `api.onUserDeleting`: unlink every Steam id this account holds.
*
* The same `unlinkOwned` the player's Rust page calls, one link at a time, each
* logged as `rust.account.unlink` with `reason: 'account deleted'`. Then every
* server's permission sync is marked dirty: the desired set reaches a grant's
* Steam id through the link, so with the links gone the grants have already
* left it, and the next tick pushes the withdrawal instead of waiting for the
* digest check.
*
* Nothing here waits on a game. The link is the site's own record and the plugin
* keeps none (the header above), so there is never pending work: what this does
* not finish, the cascade does.
*/
async function unlinkForDeletion({ userId, deletedBy }) {
const held = await db.listForUser(userId)
let removed = 0
for (const row of held) {
// eslint-disable-next-line no-await-in-loop
if (await unlinkOwned(row.steamId, userId)) {
removed += 1
// eslint-disable-next-line no-await-in-loop
await core.activity.log({
userId,
action: 'rust.account.unlink',
detail: { steamId: row.steamId, reason: 'account deleted', deletedBy },
})
}
}
if (removed > 0) await permissionsDb.markDirty(null)
log.info('rust links removed before an account was deleted', { userId, deletedBy, 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)
@@ -393,5 +435,6 @@ module.exports = {
unlinkFromGame,
checkSteamSignin,
linkFromSteamSignin,
unlinkForDeletion,
forAdmin,
}

View File

@@ -173,6 +173,8 @@ function fakeApi() {
onIdentityLinked(fn) { once('onIdentityLinked'); record.hooks.onIdentityLinked = fn },
// MODULE_API 1.18.0 (the public demo's seed, DM15).
onDemoSeed(fn) { once('onDemoSeed'); record.hooks.onDemoSeed = fn },
// MODULE_API 1.20.0 (account deletion, ACCOUNT_DELETION.md §4.4).
onUserDeleting(fn) { once('onUserDeleting'); record.hooks.onUserDeleting = fn },
}
api.record = record
return api

View File

@@ -454,7 +454,11 @@ test('module.json locks the two rig-link routes and the player Steam unlink, and
{ method: 'DELETE', path: '/admin/rust/servers/:id' },
{ method: 'DELETE', path: '/player/rust/links/:steamId' },
])
assert.equal(manifest.coreApi, '^1.19.0')
// A floor, not an exact value: later contract members raise it (1.20.0 for
// account deletion), and what this test holds is that it never drops below the
// core that asks demoRefuses.
const [major, minor] = manifest.coreApi.replace(/^\^/, '').split('.').map(Number)
assert.ok(major === 1 && minor >= 19, `coreApi ${manifest.coreApi} is at least ^1.19.0`)
})
// ── The staff Steam unlink (W4) ────────────────────────────────────────────

View File

@@ -92,6 +92,12 @@ test('registers both identity hooks, so a Steam sign-in link is a Rust link (D39
assert.strictEqual(api.record.hooks.onIdentityLinked, links.linkFromSteamSignin)
})
test('registers the account-deletion hook, so a deleted account is unlinked first (AD10)', () => {
const { api } = register()
const links = require('../model/links/links.model')
assert.strictEqual(api.record.hooks.onUserDeleting, links.unlinkForDeletion)
})
test('the manifest declares what the loader requires', () => {
assert.match(manifest.id, /^[a-z][a-z0-9-]{1,31}$/)
assert.match(manifest.version, /^\d+\.\d+\.\d+/)

View File

@@ -472,6 +472,42 @@ test('the linked hook writes nothing for a link that is already there, or for an
assert.equal(ctx.teams.reconcile.calls.length, 0)
})
// ── The website account is being deleted (MODULE_API 1.20.0, AD10) ─────────
test('a deleted account loses every Steam link through the player’s own unlink, each logged', async () => {
const { ctx, queries } = withCore({ select: [[{ steamId: '7656', userId: 4 }, { steamId: STEAM, userId: 4 }]] })
const links = require('../model/links/links.model')
await links.unlinkForDeletion({ userId: 4, deletedBy: 'self' })
// The same statement `unlinkOwned` uses, scoped by user, once per link.
const dels = queries.filter((q) => q.sql.trim().toUpperCase().startsWith('DELETE'))
assert.deepEqual(dels.map((q) => q.params), [['7656', 4], [STEAM, 4]])
// What a cascade would skip: one entry per link, and the Teams re-sync.
assert.deepEqual(ctx.activity.log.calls.map((c) => c[0]), [
{ userId: 4, action: 'rust.account.unlink', detail: { steamId: '7656', reason: 'account deleted', deletedBy: 'self' } },
{ userId: 4, action: 'rust.account.unlink', detail: { steamId: STEAM, reason: 'account deleted', deletedBy: 'self' } },
])
assert.equal(ctx.teams.reconcile.calls.length, 2)
// Every server is marked dirty, so the withdrawn grants go on the next tick.
const dirty = queries.filter((q) => /UPDATE rust_perm_sync SET dirty = 1/.test(q.sql))
assert.equal(dirty.length, 1)
assert.ok(!/WHERE/.test(dirty[0].sql), 'the whole fleet, not one server')
})
test('an account with no Steam link deletes without touching the game or the sync', async () => {
const { ctx, queries } = withCore({ select: [[]] })
const links = require('../model/links/links.model')
await links.unlinkForDeletion({ userId: 4, deletedBy: 'staff' })
assert.equal(queries.filter((q) => !q.sql.trim().toUpperCase().startsWith('SELECT')).length, 0)
assert.equal(ctx.activity.log.calls.length, 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) })