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

@@ -149,6 +149,8 @@ function fakeApi() {
registerEventLeases(leases) { once('registerEventLeases'); record.eventLeases = leases },
onBoot(fn) { once('onBoot'); record.hooks.onBoot = 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
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.strictEqual(typeof api.record.hooks.onBoot, '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', () => {