docs(website): document account deletion core and MODULE_API 1.20.0

ACCOUNT_DELETION.md §6 step 2 is built (website feature/account-deletion).

- MODULE_API.md: 1.20.0, api.onUserDeleting (§1.1, §1.1 history, §2.4).
- BACKEND_DESIGN.md: reserved_usernames, DELETE /auth/me/account, the admin
  delete now running the same procedure, the demo lock row, and §6.6 on the
  procedure, the display rule and what goes and what stays.
- TEAMS.md: the forum's deleted-author rule is now "[deleted player]" for
  members and the stored name, marked, for staff.
- ACCOUNT_DELETION.md: AD12 (wrong password or code is 400, not 401) and
  AD13 (a deleted account drops off Forum guests), both asked of the org
  lead while building; §4.1 refusals carry codes; step 2 marked built.

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 18:34:28 -05:00
parent 3545e898af
commit 42c762cc13
4 changed files with 147 additions and 11 deletions

View File

@@ -26,13 +26,33 @@ here extends the contract first, in this file, before the module is written agai
Core exports a single integer-major semver string from `server/src/modules/version.js`:
```js
const MODULE_API_VERSION = '1.19.0'
const MODULE_API_VERSION = '1.20.0'
```
The client half carries the same number (`client/src/modules/version.js`) and a test asserts the two
agree. Duplicated rather than fetched because the value has to be on `window.__rg` before the first
module chunk evaluates, which is earlier than any network round trip could answer.
**1.20.0 — a module hears that an account is about to be deleted** (`website/ACCOUNT_DELETION.md`
§4.1, AD10, AD11). One optional addition and no removal, so minor.
- **`api.onUserDeleting(fn)`** (§2.4): `fn({ userId, deletedBy })`, where `deletedBy` is `'self'` (the
member, from `DELETE /auth/me/account`) or `'staff'` (`DELETE /admin/users/:id`). Called **before**
the `users` row is deleted, while the module can still read the person's links.
- **Its job is to undo in the game what the site's rows are about to stop saying.** Unlink each game
link through the module's own unlink, so the game sees exactly what a player's own unlink produces,
and clean up anything the module keeps with no foreign key to `users`. A cascade is not an unlink:
for UO the link lives on the shard, and dropping the site's mirror alone would leave the shard
attributing the game account to a user who no longer exists.
- **It cannot veto.** A hook that throws, or runs past core's **10 s**, is logged and the deletion goes
ahead: a member's deletion request must be honoured, and a game server that is down must not hold it
open. A module that cannot finish keeps its own record of what is left (UO's
`shard_pending_unlinks`) and retries.
- **`started`** modules only, like the identity hooks; registered once. The payload is frozen.
- Module-uo's `^1.10.0` still resolves and registers none until its own PR: with no hook, a deletion
is core's cascade alone, as the admin route's delete always was. `website/USER_SAFETY.md`'s
`reportTargets` moves to 1.21.0.
**1.19.0 — the public demo refuses a step at save** (`website/DEMO_MODE.md` §3.4, DM18). One optional
member on an event action, so minor.
@@ -552,6 +572,11 @@ branches lacks — so it is the only place `permits` is true between two values
between `admin` and `owner`, `members` or `subscribers`. `permits`, `meet` and `meetAll` are otherwise
unchanged, and so is every rule about composition narrowing rather than widening.
**1.20.0 — account deletion** (`website/ACCOUNT_DELETION.md` §4.1, AD10, AD11). `api.onUserDeleting`,
called before an account is deleted so the module unlinks its game links; it cannot veto, and a hook
past 10 s is logged and skipped. One optional addition, so minor; see §1.1. Module-uo's `^1.10.0`
still resolves.
**1.19.0 — a step refused at save on the demo** (`website/DEMO_MODE.md` §3.4, DM18). An event action's
optional `demoRefuses(params)`, asked at create, save and publish with `DEMO_MODE` on; one optional
member, so minor; see §1.1. Module-uo's `^1.10.0` still resolves.
@@ -1009,6 +1034,7 @@ api.onShutdown(async () => {})
api.onIdentityLinking(async ({ userId, provider, subject }) => ({ ok: true })) // 1.17.0
api.onIdentityLinked(async ({ userId, provider, subject, displayName }) => {}) // 1.17.0
api.onDemoSeed(async (data, { accounts }) => {}) // 1.18.0
api.onUserDeleting(async ({ userId, deletedBy }) => {}) // 1.20.0
```
**Every call STAGES; nothing is committed until the module as a whole is known good.** A claim's
@@ -1528,6 +1554,25 @@ api.onDemoSeed(async ({ servers = [], links = [] }, { accounts }) => {
seed's own process. A fixture section for a module that registered no hook fails the seed. So does a
hook that throws.
**`onUserDeleting(fn)`** (1.20.0) — the module's part in deleting an account (`ACCOUNT_DELETION.md`
§4.1, AD10, AD11). Core calls `fn({ userId, deletedBy })` **before** the `users` row goes, from the one
deletion procedure both routes share (`auth/accountDeletion.js`), with `deletedBy` `'self'` or
`'staff'`.
```js
api.onUserDeleting(async ({ userId }) => {
for (const link of await links.forUser(userId)) await links.unlinkOwned(link.steamId, userId, { reason: 'account deleted' })
})
```
- **Unlink through the module's own unlink**, for every link the person holds, so the game sees what a
player's own unlink produces, with its audit entry and any re-sync. Then clean up rows kept with no
foreign key to `users`; rows that cascade need nothing.
- **It cannot stop the deletion.** A throw, or a hook still running after **10 s**, is logged and core
deletes the account anyway. Anything the module could not finish (a game server that is offline) is
the module's to record and retry: core never calls the hook twice for one deletion.
- `started` modules only, in registration order; registered once; the payload is frozen.
### 2.5 Lifecycle
```