feat(account): unlink game accounts on the shard before an account is deleted (MODULE_API 1.20.0)
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

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
This commit is contained in:
2026-10-29 20:38:33 -05:00
parent 6246383962
commit 582a55dde5
16 changed files with 630 additions and 9 deletions

View File

@@ -0,0 +1,154 @@
// ── 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 }

View File

@@ -21,6 +21,7 @@ const { settings: settingsModel, events: coreEvents } = require('../core')
const broadcaster = require('./shardBroadcast')
const shardPush = require('./shardPush')
const shardEngagement = require('./shardEngagement')
const accountDeletion = require('./accountDeletion')
const defaultLog = require('../core').logger('shard-ingest')
// Notable kinds appended to the shard_events log. High-frequency/session kinds
@@ -103,7 +104,7 @@ async function resolveShardName(shard, deps) {
// Apply the state-change side effect for a kind (if any). Returns a promise.
async function applyStateChange(event, deps) {
const { shardState, uoLinkConfig, eventsReconcile, fromBackfill, log } = deps
const { shardState, uoLinkConfig, eventsReconcile, pendingUnlinks, fromBackfill, log } = deps
switch (event.kind) {
case 'server.hello': {
const incoming = event.bootId || null
@@ -142,6 +143,10 @@ async function applyStateChange(event, deps) {
// missed by skipping these — core asks every module at its own boot.
eventsReconcile()
}
// The shard is (back) on the bridge, so an unlink an account deletion left
// queued can be asked again. Live only, like the reconcile above: a replay
// says nothing about the shard being up now. Not awaited; it never rejects.
if (!fromBackfill) pendingUnlinks.retryPending()
return
}
case 'server.shutdown':
@@ -162,6 +167,16 @@ async function applyStateChange(event, deps) {
y: event.y,
z: event.z,
})
// A queued unlink whose account now logs in without the deleted user's id
// is done. Live only: a replayed login from before the link carries no
// `webId` and would clear a row whose tag is still on the shard.
if (!fromBackfill && who.acct) {
try {
await pendingUnlinks.seenLogin({ account: who.acct, webId: event.webId })
} catch (err) {
log.warn('could not check a login against the queued unlinks', { message: err.message })
}
}
return
}
case 'mob.logout': {
@@ -323,6 +338,10 @@ function resolveDeps(deps) {
// reconcile must be able to see the call without a live event engine behind
// it.
eventsReconcile: deps.eventsReconcile || (() => coreEvents.reconcile()),
// MODULE_API 1.20.0 (ACCOUNT_DELETION.md §4.4): the unlinks an account
// deletion could not finish, retried when the shard says hello and cleared
// when a login shows the tag gone.
pendingUnlinks: deps.pendingUnlinks || accountDeletion,
// Not injectable — it is the caller's statement about this frame rather than
// a dependency. It reaches `applyStateChange` because the reconcile below is
// the one state change that must not act on a replay; see the note there.

View File

@@ -18,6 +18,7 @@ const uoLinkClient = require('./uoLinkClient')
const shardIngest = require('./shardIngest')
const shardState = require('../model/shardState/shardState.model')
const newsGump = require('./newsGump')
const accountDeletion = require('./accountDeletion')
const log = require('../core').logger('uo-link-socket')
const BACKOFF_MIN_MS = 1000
@@ -232,6 +233,10 @@ async function handleOpen() {
backoff = BACKOFF_MIN_MS
await uoLinkConfig.recordStatus({ status: 'connected', statusDetail: null, pluginConnected: true }).catch(() => {})
await backfill()
// Unlinks an account deletion left queued while the bridge was away
// (ACCOUNT_DELETION.md §4.4). This is also the boot path: onBoot starts this
// socket, and boot itself never calls the shard. Not awaited; never rejects.
accountDeletion.retryPending()
}
// A ws.hello frame: mark it seen and, on a protocol mismatch, record the error