ACCOUNT_DELETION.md phase 2 (core): a member deletes their own account on
the website, and the admin route runs the same procedure.
- DELETE /api/v1/auth/me/account: typed username, password always (AD7),
and a 2FA or recovery code when 2FA is on. A wrong password or code is
400, not 401 (AD12), so the app's refresh-on-401 never replays it.
409 password_required and last_admin; the demo's shared logins are
locked.
- auth/accountDeletion.js: module hooks first, then one transaction that
reserves a staff username (reserved_usernames, AD9) and deletes the row,
then a Teams reconcile, the audit entry (no username) and a notice to
the old address (new protected template auth.account-deleted).
- MODULE_API 1.20.0: api.onUserDeleting({ userId, deletedBy }), before the
delete, 10 s limit, cannot veto (AD10, AD11).
- utils/displayAuthor.js: a deleted account's username snapshots read
"[deleted player]" to members and the stored name, marked, to staff
(AD5, AD6). Used by the forum, the Reports queue, the guest list and
the Team digest. A deleted account drops off Forum guests (AD13).
- Reserved names refuse at every username write, answered as "taken".
- Web: Delete my account on the player and staff Account pages; staff
views mark "(deleted account)".
- OpenAPI regenerated; route manifest and engagement manifest regenerated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
88 lines
3.2 KiB
JavaScript
88 lines
3.2 KiB
JavaScript
// ── api.onUserDeleting (MODULE_API 1.20.0, ACCOUNT_DELETION.md AD10) ───────
|
|
//
|
|
// Registration and collection only; what the deletion does with the hook is in
|
|
// accountDeletion.test.js. Each test builds a throwaway modules directory and
|
|
// re-requires the loader, as moduleLoader.test.js does.
|
|
process.env.DB_HOST = '127.0.0.1'
|
|
process.env.DB_PORT = '59999'
|
|
|
|
const fs = require('fs')
|
|
const os = require('os')
|
|
const path = require('path')
|
|
|
|
const { test, after } = require('node:test')
|
|
const assert = require('node:assert/strict')
|
|
const express = require('express')
|
|
|
|
const db = require('../src/utils/db')
|
|
const registries = require('../src/modules/registries')
|
|
|
|
after(() => db.close())
|
|
|
|
const emptyTiers = () => ({ public: express.Router(), admin: express.Router(), player: express.Router() })
|
|
|
|
function loadOne(server) {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-deleting-'))
|
|
const dir = path.join(root, 'rust')
|
|
fs.mkdirSync(dir)
|
|
fs.writeFileSync(path.join(dir, 'module.json'), JSON.stringify({
|
|
id: 'rust',
|
|
name: 'Rust',
|
|
version: '1.0.0',
|
|
coreApi: '^1.20.0',
|
|
server: 'index.js',
|
|
mounts: { admin: ['/rust'] },
|
|
}))
|
|
fs.writeFileSync(path.join(dir, 'index.js'), server)
|
|
process.env.MODULES_DIR = root
|
|
registries._reset()
|
|
delete require.cache[require.resolve('../src/modules/loader')]
|
|
// eslint-disable-next-line global-require
|
|
const loader = require('../src/modules/loader')
|
|
loader.load(emptyTiers())
|
|
return loader
|
|
}
|
|
const stateOf = (loader) => loader.list().find((m) => m.id === 'rust')
|
|
|
|
test('onUserDeleting is collected once the module is started, and not before', async () => {
|
|
const loader = loadOne(`module.exports = (ctx, api) => {
|
|
api.registerRoutes({ admin: { '/rust': ctx.express.Router() } })
|
|
api.onUserDeleting(async (p) => { global.__deleting = p })
|
|
}`)
|
|
assert.equal(stateOf(loader).state, 'registered')
|
|
// A registered module has not booted: it holds no link it could undo yet.
|
|
assert.deepEqual(loader.userDeletingHooks(), [])
|
|
|
|
loader.setState('rust', 'started')
|
|
const hooks = loader.userDeletingHooks()
|
|
assert.deepEqual(hooks.map((h) => h.id), ['rust'])
|
|
await hooks[0].hook({ userId: 7, deletedBy: 'self' })
|
|
assert.deepEqual(global.__deleting, { userId: 7, deletedBy: 'self' })
|
|
delete global.__deleting
|
|
|
|
loader.setState('rust', 'disabled')
|
|
assert.deepEqual(loader.userDeletingHooks(), [])
|
|
})
|
|
|
|
test('a module that registers no hook adds nothing', () => {
|
|
const loader = loadOne(`module.exports = (ctx, api) => {
|
|
api.registerRoutes({ admin: { '/rust': ctx.express.Router() } })
|
|
}`)
|
|
loader.setState('rust', 'started')
|
|
assert.deepEqual(loader.userDeletingHooks(), [])
|
|
})
|
|
|
|
test('onUserDeleting twice, or with no function, is a registration error', () => {
|
|
for (const [body, expected] of [
|
|
['api.onUserDeleting(() => {}); api.onUserDeleting(() => {})', /onUserDeleting\(\) called twice/],
|
|
['api.onUserDeleting("later")', /onUserDeleting: expected a function/],
|
|
]) {
|
|
const loader = loadOne(`module.exports = (ctx, api) => {
|
|
api.registerRoutes({ admin: { '/rust': ctx.express.Router() } })
|
|
${body}
|
|
}`)
|
|
assert.equal(stateOf(loader).state, 'startup_failed')
|
|
assert.match(stateOf(loader).reason, expected)
|
|
}
|
|
})
|