// ── A website account is being deleted (MODULE_API 1.20.0) ────────────────── // // ACCOUNT_DELETION.md §4.4. Core asks before the users row goes (AD10), and the // row's cascade would drop `shard_account_links` anyway — but that table is only // a mirror. The shard keeps the site user id on the game account and reports it // as `webId` on every `mob.login`, so a cascade alone would leave the shard // attributing the account to a user who no longer exists. The unlink here is the // staff unlink's path (usersShard.controller.js): the sidecar's // `DELETE /link/{account}` first, the mirror after. The shard account and its // characters stay (AD8). // // **An unlink that cannot finish never holds up the deletion** (AD11): // // • every account is queued in `shard_pending_unlinks` BEFORE the first call, // so a hook core abandons at its ten-second limit still leaves the rest // queued (one sidecar call may take twelve); // • a shard that is away (503, no sidecar, a timeout) leaves the row // `pending`, retried whenever the bridge comes back — the WS reconnecting, // which is also how a boot gets here, and a live `server.hello`; // • a protected staff account, which the shard refuses by design (403), is // `refused`: retrying will not help, so Admin → Shard lists it as still // linked on the shard for the shard's staff to clear by hand. // // Either kind of row goes once the shard reports a live login on that account // with no `webId`, or a different one: the tag is gone, whoever cleared it. const core = require('../core') const shardLinks = require('../model/shardLinks/shardLinks.model') const pendingDb = require('../model/shardPendingUnlinks/shardPendingUnlinks.db') const uoLinkClient = require('./uoLinkClient') const log = core.logger('account-deletion') // What the shard's audit records as the one who asked. const ACTOR = 'web:account-deletion' /** * Ask the shard to unlink one queued account, and record what it said. * * 200 and 404 both finish it: 404 means the shard has nothing to clear. Never * throws for a shard answer; a database error does throw, to the caller's catch. * * @returns {Promise<'unlinked'|'absent'|'refused'|'pending'>} */ async function attempt({ account, webUserId, reason }) { const result = await uoLinkClient.unlinkAccount({ actor: ACTOR, account }) if (result.ok || result.status === 404) { await shardLinks.removeByAccount(account) await pendingDb.remove(account) if (!result.ok) return 'absent' // No `userId` on the entry: on a retry the account is already gone, and the // activity log's user_id is a key into `users`. The id is in the detail. await core.activity.log({ action: 'shard.account.unlink', detail: { account, webUserId, reason: 'account deleted', ...(reason ? { deletedBy: reason } : {}) }, }) return 'unlinked' } const error = result.error || `status ${result.status}` if (result.status === 403) { await pendingDb.recordAttempt({ account, state: 'refused', error }) log.warn('the shard refused to unlink a deleted account’s game account (protected); listed for shard staff', { account, webUserId }) return 'refused' } await pendingDb.recordAttempt({ account, state: 'pending', error }) return 'pending' } /** * Core's `api.onUserDeleting`. Unlinks every game account this website account * holds, through the staff unlink's path. Cannot stop the deletion; whatever it * does not finish stays queued. */ async function onUserDeleting({ userId, deletedBy }) { const links = await shardLinks.listForUser(userId) if (!links.length) return for (const { account } of links) { // eslint-disable-next-line no-await-in-loop await pendingDb.queue({ account, webUserId: userId }) } const outcomes = [] for (const { account } of links) { try { // eslint-disable-next-line no-await-in-loop outcomes.push({ account, outcome: await attempt({ account, webUserId: userId, reason: deletedBy }) }) } catch (err) { log.warn('an unlink before an account deletion failed; it stays queued', { account, error: err.message }) outcomes.push({ account, outcome: 'pending' }) } } log.info('game accounts unlinked before an account was deleted', { userId, deletedBy, outcomes }) } let inFlight = null /** * Try every `pending` row once more. One pass at a time: a reconnect and a * `server.hello` arriving together share the pass already running. Every row is * tried, so one account the shard keeps failing on does not hold up the rest. * Never rejects, so a caller may leave it un-awaited. */ function retryPending() { if (inFlight) return inFlight inFlight = (async () => { try { const rows = await pendingDb.listPending() for (const row of rows) { // eslint-disable-next-line no-await-in-loop const outcome = await attempt({ account: row.account, webUserId: row.web_user_id }) if (outcome !== 'pending') log.info('a deleted account’s queued unlink finished', { account: row.account, outcome }) } } catch (err) { log.warn('retrying queued unlinks failed (will try on the next reconnect)', { error: err.message }) } finally { inFlight = null } })() return inFlight } /** * A live `mob.login`: when this account is queued and the shard no longer * reports the deleted user's id on it, the row is done. Never on a backfill — * a replayed login from before the account was linked carries no `webId` and * would clear a row whose tag is still there. */ async function seenLogin({ account, webId }) { if (!account) return const id = webId === undefined || webId === null || webId === '' ? null : Number(webId) const dropped = await pendingDb.clearIfNotAttributed(account, Number.isFinite(id) ? id : null) if (dropped) log.info('a queued unlink cleared itself: the shard no longer attributes the account', { account }) } /** Admin → Shard's list: what a deletion left linked on the shard. */ async function listForAdmin() { const rows = await pendingDb.listAll() return rows.map((r) => ({ account: r.account, webUserId: r.web_user_id, state: r.state, queuedAt: r.queued_at, attempts: Number(r.attempts) || 0, lastAttemptAt: r.last_attempt_at || null, lastError: r.last_error || null, })) } function _reset() { inFlight = null } module.exports = { ACTOR, onUserDeleting, retryPending, seenLogin, listForAdmin, _reset }