feat(account): unlink game accounts on the shard before an account is deleted (MODULE_API 1.20.0) #47

Merged
whitlocktech merged 1 commits from feature/account-deletion-hook into main 2026-10-29 23:47:35 -05:00
16 changed files with 630 additions and 9 deletions

View File

@@ -1,6 +1,6 @@
{ {
"$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/uo and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves. Pinned rather than tracking a branch on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together. **It pointed at `edge` for the length of the Event System window** (org lead, 2026-09-04), and this commit ends that: `api.registerEventActions` exists only from MODULE_API 1.10.0, so under the previous `main` pin `register()` threw and the module did not load at all — the job would have been red by construction for eight phases and would have proved nothing while a real regression hid behind it. The Phase 16b cutover put 1.10.0 on `main`, so the pin comes home, and this is the same move that turns the Integration kit green again. **routes.manifest.json needed NO regeneration**: the job's own steps were run against this exact ref and answered `routes.manifest.json is current — 73 routes, all documented`, so the \"commit both together\" instruction above had nothing to pair with this time.", "$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/uo and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves. Pinned rather than tracking a branch on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together. **It pointed at `edge` for the length of the Event System window** (org lead, 2026-09-04), and this commit ends that: `api.registerEventActions` exists only from MODULE_API 1.10.0, so under the previous `main` pin `register()` threw and the module did not load at all — the job would have been red by construction for eight phases and would have proved nothing while a real regression hid behind it. The Phase 16b cutover put 1.10.0 on `main`, so the pin comes home, and this is the same move that turns the Integration kit green again. **routes.manifest.json needed NO regeneration**: the job's own steps were run against this exact ref and answered `routes.manifest.json is current — 73 routes, all documented`, so the \"commit both together\" instruction above had nothing to pair with this time. **2026-10-30, account deletion (ACCOUNT_DELETION.md §4.4):** this module registers onUserDeleting (MODULE_API 1.20.0), so coreApi is ^1.20.0 and the pin moves past 1.11.0-1.19.0 to `main` at the sha website#232 merged as. A 1.19.0 core has no api.onUserDeleting, so register() would throw under the old pin.",
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git", "repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
"ref": "655fbf3f69a6a1fd650ecbc81afd6cf9c2ad9f66", "ref": "7bd93b8bb0da734a642587f256d10c523ca39bea",
"refName": "main @ MODULE_API 1.10.0, the Event System cutover (website#199)" "refName": "main @ MODULE_API 1.20.0, account deletion (website#232)"
} }

View File

@@ -83,6 +83,39 @@ function StatusPanel({ config }) {
) )
} }
// ── Unlinks a deleted website account left behind ───────────────────────────
//
// ACCOUNT_DELETION.md §4.4. Deleting a website account unlinks its game accounts
// on the shard first; what the shard could not take is listed here until it
// clears. `pending` is retried by itself when the bridge reconnects. `refused` is
// a protected staff account the shard will not unlink over the bridge, so the
// shard's staff clear its WebsiteUserId tag by hand. A row goes on its own once a
// login on that account no longer carries the deleted user's id.
function PendingUnlinks({ rows }) {
if (!rows || rows.length === 0) return null
return (
<section style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16, display: 'flex', flexDirection: 'column', gap: 10 }}>
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Still linked on the shard</h3>
<p className="sans dim" style={{ margin: 0, fontSize: '0.82rem' }}>
Game accounts whose website account was deleted, but whose link the shard has not cleared yet.
</p>
<ul style={{ margin: 0, padding: 0, listStyle: 'none', display: 'flex', flexDirection: 'column', gap: 8 }}>
{rows.map((r) => (
<li key={r.account} className="sans" style={{ fontSize: '0.85rem', color: 'var(--ink)' }}>
<strong>{r.account}</strong>
<span className="dim"> · deleted user #{r.webUserId} · {new Date(r.queuedAt).toLocaleString()}</span>
<div className="dim" style={{ fontSize: '0.78rem', marginTop: 2 }}>
{r.state === 'refused'
? 'Protected staff account: the shard will not unlink it over the bridge. Clear its link on the shard by hand.'
: `Waiting for the shard; tried ${r.attempts} time${r.attempts === 1 ? '' : 's'}, and tried again when the bridge reconnects.`}
</div>
</li>
))}
</ul>
</section>
)
}
// ── Game-account signup ───────────────────────────────────────────────────── // ── Game-account signup ─────────────────────────────────────────────────────
// //
// This field lived in core's Site Settings until slice 3 of the extraction. It // This field lived in core's Site Settings until slice 3 of the extraction. It
@@ -285,6 +318,8 @@ export default function ShardAdmin() {
<StatusPanel config={config} /> <StatusPanel config={config} />
<PendingUnlinks rows={config.pendingUnlinks} />
<Toggle checked={enabled} onChange={setEnabled} label="Enable the shard integration" /> <Toggle checked={enabled} onChange={setEnabled} label="Enable the shard integration" />
<label style={{ display: 'block' }}> <label style={{ display: 'block' }}>

View File

@@ -2,7 +2,7 @@
"id": "uo", "id": "uo",
"name": "Ultima Online", "name": "Ultima Online",
"version": "0.6.0", "version": "0.6.0",
"coreApi": "^1.10.0", "coreApi": "^1.20.0",
"server": "server/index.js", "server": "server/index.js",
"client": { "entry": "client/dist/entry.js" }, "client": { "entry": "client/dist/entry.js" },
"schema": "server/db/schema.sql", "schema": "server/db/schema.sql",

View File

@@ -27,6 +27,10 @@
-- marker, and deleting it would re-arm a protocol bump against tables this -- marker, and deleting it would re-arm a protocol bump against tables this
-- file has just dropped. -- file has just dropped.
-- Account deletion's queue (MODULE_API 1.20.0), the newest table. No foreign key:
-- its rows outlive the users they name, by design.
DROP TABLE IF EXISTS `shard_pending_unlinks`;
-- The Asset Bridge's three (phase 3). No foreign keys of their own, so they lead: -- The Asset Bridge's three (phase 3). No foreign keys of their own, so they lead:
-- `shard_creature_bodies.slug` mirrors an atlas slug and `shard_assets.body` a body -- `shard_creature_bodies.slug` mirrors an atlas slug and `shard_assets.body` a body
-- id, but neither is declared as a constraint — the atlas tables are rebuilt from -- id, but neither is declared as a constraint — the atlas tables are rebuilt from

View File

@@ -974,3 +974,27 @@ INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_8_migrated'
-- applied to an empty database. That is the same blind spot the protocol-pin block -- applied to an empty database. That is the same blind spot the protocol-pin block
-- above records, two phases running. -- above records, two phases running.
ALTER TABLE shard_spawn_points ADD COLUMN IF NOT EXISTS unique_id VARCHAR(64) NULL; ALTER TABLE shard_spawn_points ADD COLUMN IF NOT EXISTS unique_id VARCHAR(64) NULL;
-- Game-account unlinks a website account's deletion could not finish
-- (MODULE_API 1.20.0; ACCOUNT_DELETION.md §4.4, AD11). `utils/accountDeletion.js`
-- queues every linked account BEFORE it asks the shard, and drops the row when the
-- shard answers 200 or 404.
-- pending the shard was away (503, no sidecar, a timeout): retried when the
-- bridge reconnects and on a live server.hello
-- refused a protected staff account (403): never retried; Admin → Shard lists
-- it as still linked on the shard, for the shard's staff to clear
-- Either goes once a live mob.login on the account carries no webId, or another.
--
-- **No foreign key, deliberately.** `web_user_id` names a user who is gone by the
-- time the row matters; it is the id the shard still reports, kept so a login can
-- show the tag has gone.
CREATE TABLE IF NOT EXISTS shard_pending_unlinks (
account VARCHAR(120) NOT NULL PRIMARY KEY,
web_user_id INT NOT NULL,
state VARCHAR(16) NOT NULL DEFAULT 'pending',
queued_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
attempts INT NOT NULL DEFAULT 0,
last_attempt_at DATETIME NULL,
last_error VARCHAR(255) NULL,
INDEX idx_shard_pending_unlinks_state (state)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@@ -52,6 +52,7 @@ const engagementSeeds = require('./config/engagementSeeds')
const teamProvider = require('./model/teamProvider/teamProvider.model') const teamProvider = require('./model/teamProvider/teamProvider.model')
const guildCommand = require('./commands/guild.command') const guildCommand = require('./commands/guild.command')
const boot = require('./boot') const boot = require('./boot')
const accountDeletion = require('./utils/accountDeletion')
/* eslint-enable global-require */ /* eslint-enable global-require */
const log = core.logger() const log = core.logger()
@@ -184,6 +185,13 @@ const engagementSeeds = require('./config/engagementSeeds')
api.onBoot(boot.onBoot) api.onBoot(boot.onBoot)
api.onShutdown(boot.onShutdown) api.onShutdown(boot.onShutdown)
// Account deletion (MODULE_API 1.20.0; ACCOUNT_DELETION.md §4.4, AD10, AD11).
// Core asks before the users row goes: each linked game account is unlinked on
// the shard through the staff unlink's path, so the shard stops reporting the
// deleted user's id. It cannot stop the deletion; what the shard could not take
// now waits in `shard_pending_unlinks` and is retried when the bridge is back.
api.onUserDeleting(accountDeletion.onUserDeleting)
log.info('registered', { log.info('registered', {
version: require('../module.json').version, version: require('../module.json').version,
routes: 'public:/shard,/atlas admin:/shard,/uo-link player:/shard', routes: 'public:/shard,/atlas admin:/shard,/uo-link player:/shard',

View File

@@ -0,0 +1,50 @@
// Game-account unlinks that a website account's deletion could not finish
// (ACCOUNT_DELETION.md §4.4, AD11). See utils/accountDeletion.js for who writes
// and who clears these rows.
const { query } = require('../../core')
const COLS = 'account, web_user_id, state, queued_at, attempts, last_attempt_at, last_error'
// Queue an account before the first call to the shard, so a hook core abandons
// at its time limit still leaves the account here. A re-queue (the same account
// deleted twice is impossible, but a test or a retry is not) starts it over.
const queue = ({ account, webUserId }) =>
query(
`INSERT INTO shard_pending_unlinks (account, web_user_id, state)
VALUES (?, ?, 'pending')
ON DUPLICATE KEY UPDATE web_user_id = VALUES(web_user_id), state = 'pending',
attempts = 0, last_attempt_at = NULL, last_error = NULL`,
[account, webUserId],
)
const remove = (account) => query('DELETE FROM shard_pending_unlinks WHERE account = ?', [account])
// One attempt that did not finish. `state` is `pending` (try again) or `refused`
// (the shard said no for good: a protected staff account).
const recordAttempt = ({ account, state, error }) =>
query(
`UPDATE shard_pending_unlinks
SET state = ?, attempts = attempts + 1, last_attempt_at = CURRENT_TIMESTAMP, last_error = ?
WHERE account = ?`,
[state, error ? String(error).slice(0, 255) : null, account],
)
const listPending = () =>
query(`SELECT ${COLS} FROM shard_pending_unlinks WHERE state = 'pending' ORDER BY queued_at ASC`)
const listAll = () => query(`SELECT ${COLS} FROM shard_pending_unlinks ORDER BY queued_at ASC`)
// The shard just reported this account logging in with `webId` (or none). When
// that is no longer the deleted user, the tag is gone and the row has nothing
// left to wait for. Returns the number of rows dropped.
async function clearIfNotAttributed(account, webId) {
const result = await query(
`DELETE FROM shard_pending_unlinks
WHERE account = ? AND (? IS NULL OR web_user_id <> ?)`,
[account, webId, webId],
)
return Number(result && result.affectedRows) || 0
}
module.exports = { queue, remove, recordAttempt, listPending, listAll, clearIfNotAttributed }

View File

@@ -11,11 +11,15 @@ const uoLinkClient = require('../../utils/uoLinkClient')
const uoLinkSocket = require('../../utils/uoLinkSocket') const uoLinkSocket = require('../../utils/uoLinkSocket')
const shardBroadcast = require('../../utils/shardBroadcast') const shardBroadcast = require('../../utils/shardBroadcast')
const gameSignup = require('../../utils/gameSignup') const gameSignup = require('../../utils/gameSignup')
const accountDeletion = require('../../utils/accountDeletion')
const { activity } = require('../../core') const { activity } = require('../../core')
const log = require('../../core').logger('admin-uolink') const log = require('../../core').logger('admin-uolink')
// Assemble the masked config + live health + ingestion stats for the panel. // Assemble the masked config + live health + ingestion stats for the panel, and
// the game accounts a website account's deletion left linked on the shard
// (ACCOUNT_DELETION.md §4.4): `pending` waits for the shard, `refused` is a
// protected staff account the shard's staff must clear by hand.
async function buildStatus() { async function buildStatus() {
const config = await uoLinkConfig.getSafe() const config = await uoLinkConfig.getSafe()
const health = await uoLinkClient.health() const health = await uoLinkClient.health()
@@ -24,6 +28,7 @@ async function buildStatus() {
health: health.ok ? health.data : { ok: false, error: health.error || `status ${health.status}` }, health: health.ok ? health.data : { ok: false, error: health.error || `status ${health.status}` },
ingest: uoLinkSocket.getState(), ingest: uoLinkSocket.getState(),
sse: shardBroadcast.stats(), sse: shardBroadcast.stats(),
pendingUnlinks: await accountDeletion.listForAdmin(),
} }
} }

View File

@@ -35,7 +35,8 @@ uoLinkRouter.get(
// #swagger.tags = ['Admin · Shard'] // #swagger.tags = ['Admin · Shard']
// #swagger.summary = 'Get uo-link config + live status + ingestion stats (admin only)' // #swagger.summary = 'Get uo-link config + live status + ingestion stats (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Masked config, health and ingestion stats', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ // #swagger.description = 'pendingUnlinks lists the game accounts a website account deletion left linked on the shard: state pending (the shard was away; retried when the bridge reconnects) or refused (a protected staff account; shard staff clear it by hand). A row goes once the shard reports a login on the account without the deleted user id.'
/* #swagger.responses[200] = { description: 'Masked config, health, ingestion stats and pendingUnlinks', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly, adminOnly,
uoLink.getConfig, uoLink.getConfig,

View File

@@ -149,6 +149,8 @@ function fakeApi() {
registerEventLeases(leases) { once('registerEventLeases'); record.eventLeases = leases }, registerEventLeases(leases) { once('registerEventLeases'); record.eventLeases = leases },
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn }, onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn }, onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
// MODULE_API 1.20.0 (account deletion, ACCOUNT_DELETION.md §4.4).
onUserDeleting(fn) { once('onUserDeleting'); record.hooks.onUserDeleting = fn },
} }
api.record = record api.record = record
return api return api

View File

@@ -0,0 +1,312 @@
// ── A website account is being deleted (MODULE_API 1.20.0) ──────────────────
//
// ACCOUNT_DELETION.md §4.4, AD10 and AD11. What these hold:
//
// • the shard is asked to unlink every game account, through the same sidecar
// call the staff unlink makes, and the mirror goes only when it said yes;
// • every account is queued BEFORE the first call, so a hook core abandons at
// its ten-second limit still leaves the rest in the queue;
// • a shard that is away leaves the row pending; a protected account (403)
// leaves it refused and is never asked again;
// • a live login without the deleted user's id clears the row, and a replayed
// one never does.
const { test, afterEach } = require('node:test')
const assert = require('node:assert/strict')
const { ctx } = require('./_setup')
const accountDeletion = require('../utils/accountDeletion')
const shardIngest = require('../utils/shardIngest')
const shardLinks = require('../model/shardLinks/shardLinks.model')
const pendingDb = require('../model/shardPendingUnlinks/shardPendingUnlinks.db')
const uoLinkClient = require('../utils/uoLinkClient')
const originals = {
listForUser: shardLinks.listForUser,
removeByAccount: shardLinks.removeByAccount,
unlinkAccount: uoLinkClient.unlinkAccount,
pending: { ...pendingDb },
activityLog: ctx.activity.log,
}
afterEach(() => {
shardLinks.listForUser = originals.listForUser
shardLinks.removeByAccount = originals.removeByAccount
uoLinkClient.unlinkAccount = originals.unlinkAccount
Object.assign(pendingDb, originals.pending)
ctx.activity.log = originals.activityLog
accountDeletion._reset()
})
/** An in-memory `shard_pending_unlinks`, and a journal of everything asked of it. */
function fakeQueue(rows = []) {
const table = new Map(rows.map((r) => [r.account, { attempts: 0, state: 'pending', ...r }]))
const journal = []
pendingDb.queue = async ({ account, webUserId }) => {
journal.push(['queue', account])
table.set(account, { account, web_user_id: webUserId, state: 'pending', attempts: 0 })
}
pendingDb.remove = async (account) => {
journal.push(['remove', account])
table.delete(account)
}
pendingDb.recordAttempt = async ({ account, state, error }) => {
journal.push(['attempt', account, state])
const row = table.get(account)
if (row) Object.assign(row, { state, attempts: row.attempts + 1, last_error: error })
}
pendingDb.listPending = async () => [...table.values()].filter((r) => r.state === 'pending')
pendingDb.listAll = async () => [...table.values()]
pendingDb.clearIfNotAttributed = async (account, webId) => {
const row = table.get(account)
if (!row || (webId !== null && row.web_user_id === webId)) return 0
table.delete(account)
return 1
}
return { table, journal }
}
/** The sidecar, answering per account from a script. */
function fakeShard(replies) {
const asked = []
uoLinkClient.unlinkAccount = async ({ actor, account }) => {
asked.push({ actor, account })
return replies[account]
}
return asked
}
function trackMirror() {
const removed = []
shardLinks.removeByAccount = async (account) => { removed.push(account) }
return removed
}
function trackActivity() {
const entries = []
ctx.activity.log = async (entry) => { entries.push(entry) }
return entries
}
const OK = { ok: true, status: 200, data: { ok: true } }
const ABSENT = { ok: false, status: 404 }
const PROTECTED = { ok: false, status: 403, error: 'protected' }
const AWAY = { ok: false, status: 503, error: 'plugin not connected' }
test('every game account is unlinked on the shard, then dropped from the mirror and logged', async () => {
shardLinks.listForUser = async () => [{ account: 'thorne' }, { account: 'thorne2' }]
const { table } = fakeQueue()
const asked = fakeShard({ thorne: OK, thorne2: OK })
const mirror = trackMirror()
const entries = trackActivity()
await accountDeletion.onUserDeleting({ userId: 7, deletedBy: 'self' })
// The staff unlink's sidecar call, with an actor that says why.
assert.deepEqual(asked, [
{ actor: 'web:account-deletion', account: 'thorne' },
{ actor: 'web:account-deletion', account: 'thorne2' },
])
assert.deepEqual(mirror, ['thorne', 'thorne2'])
assert.equal(table.size, 0, 'nothing left queued')
// No `userId` column: on a retry the user is already gone.
assert.deepEqual(entries, [
{ action: 'shard.account.unlink', detail: { account: 'thorne', webUserId: 7, reason: 'account deleted', deletedBy: 'self' } },
{ action: 'shard.account.unlink', detail: { account: 'thorne2', webUserId: 7, reason: 'account deleted', deletedBy: 'self' } },
])
})
test('every account is queued before the shard is asked anything', async () => {
// A hook core abandons at ten seconds keeps running, but core deletes the user
// meanwhile; one sidecar call can take twelve. Queued first, nothing is lost.
shardLinks.listForUser = async () => [{ account: 'a' }, { account: 'b' }]
const { journal } = fakeQueue()
fakeShard({ a: OK, b: OK })
trackMirror()
trackActivity()
await accountDeletion.onUserDeleting({ userId: 7, deletedBy: 'staff' })
assert.deepEqual(journal.slice(0, 2), [['queue', 'a'], ['queue', 'b']])
})
test('a shard that is away leaves the account pending, and the mirror to the cascade', async () => {
shardLinks.listForUser = async () => [{ account: 'thorne' }]
const { table } = fakeQueue()
fakeShard({ thorne: AWAY })
const mirror = trackMirror()
const entries = trackActivity()
await accountDeletion.onUserDeleting({ userId: 7, deletedBy: 'self' })
assert.equal(table.get('thorne').state, 'pending')
assert.equal(table.get('thorne').web_user_id, 7)
assert.equal(table.get('thorne').last_error, 'plugin not connected')
assert.deepEqual(mirror, [])
assert.deepEqual(entries, [])
})
test('a protected staff account is refused, and a retry never asks again', async () => {
shardLinks.listForUser = async () => [{ account: 'gm' }]
const { table } = fakeQueue()
const asked = fakeShard({ gm: PROTECTED })
trackMirror()
trackActivity()
await accountDeletion.onUserDeleting({ userId: 7, deletedBy: 'self' })
assert.equal(table.get('gm').state, 'refused')
await accountDeletion.retryPending()
assert.equal(asked.length, 1, 'the retry skipped the refused row')
// Admin → Shard sees it.
const listed = await accountDeletion.listForAdmin()
assert.equal(listed.length, 1)
assert.equal(listed[0].account, 'gm')
assert.equal(listed[0].state, 'refused')
})
test('a 404 means the shard has nothing to clear: the row and the mirror go, with no unlink entry', async () => {
shardLinks.listForUser = async () => [{ account: 'thorne' }]
const { table } = fakeQueue()
fakeShard({ thorne: ABSENT })
const mirror = trackMirror()
const entries = trackActivity()
await accountDeletion.onUserDeleting({ userId: 7, deletedBy: 'self' })
assert.equal(table.size, 0)
assert.deepEqual(mirror, ['thorne'])
assert.deepEqual(entries, [])
})
test('an account with no game links asks the shard nothing', async () => {
shardLinks.listForUser = async () => []
const { journal } = fakeQueue()
const asked = fakeShard({})
await accountDeletion.onUserDeleting({ userId: 7, deletedBy: 'self' })
assert.deepEqual(journal, [])
assert.deepEqual(asked, [])
})
test('a call that throws leaves the account queued and the hook still resolves', async () => {
shardLinks.listForUser = async () => [{ account: 'a' }, { account: 'b' }]
const { table } = fakeQueue()
uoLinkClient.unlinkAccount = async ({ account }) => {
if (account === 'a') throw new Error('socket hang up')
return OK
}
trackMirror()
trackActivity()
await accountDeletion.onUserDeleting({ userId: 7, deletedBy: 'self' })
assert.deepEqual([...table.keys()], ['a'])
})
test('a retry finishes what the shard can take and keeps the rest', async () => {
const { table } = fakeQueue([
{ account: 'a', web_user_id: 7 },
{ account: 'b', web_user_id: 7 },
])
// One account failing does not hold up the other.
const asked = fakeShard({ a: AWAY, b: OK })
trackMirror()
trackActivity()
await accountDeletion.retryPending()
assert.deepEqual(asked.map((x) => x.account), ['a', 'b'])
assert.deepEqual([...table.keys()], ['a'])
assert.equal(table.get('a').attempts, 1)
})
test('two triggers at once share one retry pass', async () => {
fakeQueue([{ account: 'a', web_user_id: 7 }])
let calls = 0
uoLinkClient.unlinkAccount = async () => {
calls += 1
await new Promise((resolve) => setTimeout(resolve, 10))
return OK
}
trackMirror()
trackActivity()
await Promise.all([accountDeletion.retryPending(), accountDeletion.retryPending()])
assert.equal(calls, 1)
})
test('a retry that cannot read the queue resolves anyway', async () => {
fakeQueue()
pendingDb.listPending = async () => { throw new Error('pool closed') }
await accountDeletion.retryPending()
})
// ── Through the ingest dispatcher ──────────────────────────────────────────
function ingestDeps(pendingUnlinks) {
const noop = async () => {}
return {
shardEvents: { append: noop },
shardState: { upsertOnline: noop, clearOnline: noop, setOffline: noop },
shardLinks: {},
shardMarket: {},
uoLinkConfig: { recordStatus: noop },
settings: { getInstanceName: async () => 'Rig' },
broadcast: () => {},
pushDispatch: () => {},
engagement: () => {},
eventsReconcile: () => {},
log: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} },
pendingUnlinks,
}
}
function spyUnlinks() {
const calls = { retry: 0, seen: [] }
return {
calls,
retryPending: () => { calls.retry += 1; return Promise.resolve() },
seenLogin: async (arg) => { calls.seen.push(arg) },
}
}
test('a live server.hello retries the queue; a replayed one does not', async () => {
const spy = spyUnlinks()
await shardIngest.ingest({ kind: 'server.hello', bootId: 'b1', t: 1 }, ingestDeps(spy))
assert.equal(spy.calls.retry, 1)
await shardIngest.ingest({ kind: 'server.hello', bootId: 'b1', t: 2 }, { ...ingestDeps(spy), fromBackfill: true })
assert.equal(spy.calls.retry, 1)
})
test('a live login is checked against the queue; a replayed one never is', async () => {
const spy = spyUnlinks()
const login = { kind: 'mob.login', who: { serial: 1, name: 'Thorne', acct: 'thorne' }, t: 1 }
await shardIngest.ingest(login, ingestDeps(spy))
assert.deepEqual(spy.calls.seen, [{ account: 'thorne', webId: undefined }])
await shardIngest.ingest({ ...login, webId: 7 }, { ...ingestDeps(spy), fromBackfill: true })
assert.equal(spy.calls.seen.length, 1)
})
test('a login without the deleted user’s id clears the row; one still carrying it does not', async () => {
const { table } = fakeQueue([
{ account: 'thorne', web_user_id: 7, state: 'refused' },
{ account: 'gm', web_user_id: 9, state: 'refused' },
])
await accountDeletion.seenLogin({ account: 'gm', webId: 9 })
await accountDeletion.seenLogin({ account: 'gm', webId: '9' })
assert.ok(table.has('gm'), 'still tagged with the deleted user')
await accountDeletion.seenLogin({ account: 'thorne', webId: undefined })
assert.ok(!table.has('thorne'), 'the tag is gone, whoever cleared it')
await accountDeletion.seenLogin({ account: 'gm', webId: 12 })
assert.ok(!table.has('gm'), 'relinked to someone else')
})

View File

@@ -131,6 +131,8 @@ test('registers exactly what module.json declares', () => {
assert.ok(api.record.streams.length > 0) assert.ok(api.record.streams.length > 0)
assert.strictEqual(typeof api.record.hooks.onBoot, 'function') assert.strictEqual(typeof api.record.hooks.onBoot, 'function')
assert.strictEqual(typeof api.record.hooks.onShutdown, 'function') assert.strictEqual(typeof api.record.hooks.onShutdown, 'function')
// MODULE_API 1.20.0: a deleted account's game links are unlinked on the shard.
assert.strictEqual(api.record.hooks.onUserDeleting, require('../utils/accountDeletion').onUserDeleting)
}) })
test('every registered stream is namespaced or grandfathered', () => { test('every registered stream is namespaced or grandfathered', () => {

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 broadcaster = require('./shardBroadcast')
const shardPush = require('./shardPush') const shardPush = require('./shardPush')
const shardEngagement = require('./shardEngagement') const shardEngagement = require('./shardEngagement')
const accountDeletion = require('./accountDeletion')
const defaultLog = require('../core').logger('shard-ingest') const defaultLog = require('../core').logger('shard-ingest')
// Notable kinds appended to the shard_events log. High-frequency/session kinds // 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. // Apply the state-change side effect for a kind (if any). Returns a promise.
async function applyStateChange(event, deps) { async function applyStateChange(event, deps) {
const { shardState, uoLinkConfig, eventsReconcile, fromBackfill, log } = deps const { shardState, uoLinkConfig, eventsReconcile, pendingUnlinks, fromBackfill, log } = deps
switch (event.kind) { switch (event.kind) {
case 'server.hello': { case 'server.hello': {
const incoming = event.bootId || null 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. // missed by skipping these — core asks every module at its own boot.
eventsReconcile() 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 return
} }
case 'server.shutdown': case 'server.shutdown':
@@ -162,6 +167,16 @@ async function applyStateChange(event, deps) {
y: event.y, y: event.y,
z: event.z, 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 return
} }
case 'mob.logout': { case 'mob.logout': {
@@ -323,6 +338,10 @@ function resolveDeps(deps) {
// reconcile must be able to see the call without a live event engine behind // reconcile must be able to see the call without a live event engine behind
// it. // it.
eventsReconcile: deps.eventsReconcile || (() => coreEvents.reconcile()), 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 // Not injectable — it is the caller's statement about this frame rather than
// a dependency. It reaches `applyStateChange` because the reconcile below is // 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. // 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 shardIngest = require('./shardIngest')
const shardState = require('../model/shardState/shardState.model') const shardState = require('../model/shardState/shardState.model')
const newsGump = require('./newsGump') const newsGump = require('./newsGump')
const accountDeletion = require('./accountDeletion')
const log = require('../core').logger('uo-link-socket') const log = require('../core').logger('uo-link-socket')
const BACKOFF_MIN_MS = 1000 const BACKOFF_MIN_MS = 1000
@@ -232,6 +233,10 @@ async function handleOpen() {
backoff = BACKOFF_MIN_MS backoff = BACKOFF_MIN_MS
await uoLinkConfig.recordStatus({ status: 'connected', statusDetail: null, pluginConnected: true }).catch(() => {}) await uoLinkConfig.recordStatus({ status: 'connected', statusDetail: null, pluginConnected: true }).catch(() => {})
await backfill() 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 // A ws.hello frame: mark it seen and, on a protocol mismatch, record the error

View File

@@ -1452,10 +1452,10 @@
"Admin · Shard" "Admin · Shard"
], ],
"summary": "Get uo-link config + live status + ingestion stats (admin only)", "summary": "Get uo-link config + live status + ingestion stats (admin only)",
"description": "", "description": "pendingUnlinks lists the game accounts a website account deletion left linked on the shard: state pending (the shard was away; retried when the bridge reconnects) or refused (a protected staff account; shard staff clear it by hand). A row goes once the shard reports a login on the account without the deleted user id.",
"responses": { "responses": {
"200": { "200": {
"description": "Masked config, health and ingestion stats", "description": "Masked config, health, ingestion stats and pendingUnlinks",
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {