Files
Module-uo/server/utils/accountDeletion.js
wtclaude 582a55dde5
All checks were successful
PR Checks / server-tests (pull_request) Successful in 1m0s
PR Checks / client-build (pull_request) Successful in 1m0s
PR Checks / frozen-manifest (pull_request) Successful in 1m20s
feat(account): unlink game accounts on the shard before an account is deleted (MODULE_API 1.20.0)
ACCOUNT_DELETION.md §4.4 (AD10, AD11). api.onUserDeleting queues every linked
account in the new shard_pending_unlinks table, then calls the sidecar's
DELETE /link/{account} (actor web:account-deletion) and drops the mirror on 200/404.
A shard that is away leaves the row pending, retried on WS reconnect and on a live
server.hello; a protected account (403) is refused and listed on Admin -> Shard as
still linked on the shard. A live mob.login without the deleted user's webId clears
the row. coreApi ^1.20.0; core-ref pinned to website#232's merge (7bd93b8).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-29 20:38:33 -05:00

155 lines
6.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── 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 }