From 42c762cc13b41010379b7058815526eacce705ed Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 29 Oct 2026 18:34:28 -0500 Subject: [PATCH] docs(website): document account deletion core and MODULE_API 1.20.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- website/ACCOUNT_DELETION.md | 33 +++++++++++++---- website/BACKEND_DESIGN.md | 71 +++++++++++++++++++++++++++++++++++-- website/MODULE_API.md | 47 +++++++++++++++++++++++- website/TEAMS.md | 7 +++- 4 files changed, 147 insertions(+), 11 deletions(-) diff --git a/website/ACCOUNT_DELETION.md b/website/ACCOUNT_DELETION.md index 9ea1263..dc9be0a 100644 --- a/website/ACCOUNT_DELETION.md +++ b/website/ACCOUNT_DELETION.md @@ -1,7 +1,8 @@ # Account deletion — a member deletes their own account, on the website and in the app -**Status:** plan of record, 2026-10-29, with every question answered (AD4-AD11). Nothing built -yet. Each phase in §6 stops for the org lead before the next one starts. +**Status:** plan of record, 2026-10-29, with every question answered (AD4-AD13). **§6 step 2 +(core) built 2026-10-29** as website `feature/account-deletion`, `MODULE_API` 1.20.0; steps 3-6 are +next. Each phase in §6 stops for the org lead before the next one starts. **Why now.** Google Play's Data safety form asks whether the app lets people create an account, and an app that does must let them delete it, both **in the app** and at a **web address** given to Play @@ -73,6 +74,17 @@ player?"), the same day: - A **protected staff account**, which the shard refuses to unlink (403), is logged and shown to admins as *still linked on the shard*, for the shard's staff to clear by hand. +Asked while building core (§6 step 2), the same day: + +- **AD12 — A wrong password or code is 400, not 401.** §4.1 first said 401. In the app, a 401 on a + signed-in call goes to `TokenAuthenticator`, which spends the refresh token and replays the request + with the same body: one typo would make two password attempts, both counted by the login backoff, and + a failed refresh would sign the member out mid-deletion. Every other self-service password check + already answers 400, so deletion does too, with `code: 'wrong_password'` or `'wrong_code'`. +- **AD13 — A deleted account drops off a Team's *Forum guests* list.** Its grant row stays (the id is + set NULL) and the staff grant ledger still shows it with the name, marked deleted. A grant is matched + by user id, so a deleted account cannot use it and is not a guest. + ## 3. What exists today (read 2026-10-29, website `main` 222bcb0) - **The admin route.** `DELETE /api/v1/admin/users/:id` refuses your own account and the last admin, @@ -149,12 +161,12 @@ player?"), the same day: - The typed `username` must match the account, ignoring case. - The password is always checked (AD7). - A two-factor code or a recovery code is checked when two-factor sign-in is on. - - Refusals: - - 400 for a username that doesn't match - - 401 for a wrong password or code + - Refusals, each with a `code`: + - 400 `username_mismatch` for a username that doesn't match + - 400 `wrong_password`, `code_required` or `wrong_code` (AD12: 400, never 401) - 409 `password_required` for an account with no password: the screen points at the Account page's set-a-password section, which already exists for that case - - 409 for the last admin + - 409 `last_admin` for the last admin - the demo's own refusal for a demo account (`demo/mode.js`, the same lock other breaking settings use) - `#swagger.*` annotations; the spec is regenerated. @@ -309,6 +321,15 @@ player?"), the same day: 2. **Core** (`website`): §4.1 and §4.2, `MODULE_API` 1.20.0, tests (the service's procedure, the route's refusals, the demo lock, a module hook that throws, `displayAuthor` per table, a reserved staff name), OpenAPI, `BACKEND_DESIGN.md`, `TEAMS.md`, `MODULE_API.md`. One PR. + **Built 2026-10-29.** Beyond the list: the section is on the staff Account page + (`AccountAdmin.jsx`) as well as the player one, since staff delete themselves too; the staff views + (Reports queue, Teams forum log, moderation requests, leader overrides, the forum itself) mark a + deleted account's stored name "(deleted account)"; a `transaction()` helper in `utils/db.js`. Walked + locally against a throwaway database: refusals, deletion on the web and over a bearer with 2FA, the + other device's session dying on its next request, staff names refused at admin create and at a + rename, a player name free at once, the admin route, the last admin refused, and the web screens + for a player and an admin. The forum's "[deleted player]" rendering and the demo lock are covered + by tests only; the walk in step 6 sees them with a game module and the demo flag on. 3. **Module-Rust** (`edge`) and **Module-uo**: §4.4's hooks plus their core-ref pins, one PR each. Module-uo's PR adds `shard_pending_unlinks`, the retry, and the *still linked on the shard* list. **Integration Kit**: the re-pin and the paragraph, one PR. diff --git a/website/BACKEND_DESIGN.md b/website/BACKEND_DESIGN.md index 8ed257f..3482fec 100644 --- a/website/BACKEND_DESIGN.md +++ b/website/BACKEND_DESIGN.md @@ -91,7 +91,7 @@ server/ mounted at the group root, LAST: the /me sub-routers below also match the bare /me and supply its noindex header - me.routes.js (23) /auth/me/account*, sessions, trusted + me.routes.js (24) /auth/me/account*, sessions, trusted devices — router-level requireAuth. The ONLY self-service account surface (see below); account.controller.js @@ -271,6 +271,19 @@ listed models/two utils. They're isolated in `middleware/` + one `activity` mode | created_at | DATETIME DEFAULT CURRENT_TIMESTAMP | also the **tie-break for de-duplication**: oldest account keeps a shared address | | last_login_at / last_login_ip | DATETIME NULL / VARCHAR(45) NULL | shown in user management | +### reserved_usernames *(account deletion, AD9)* +| col | type | notes | +|---|---|---| +| username_norm | VARCHAR(32) PK COLLATE utf8mb4_general_ci | lowercased; the `users.username` collation, so "reserved" folds case exactly as "taken" does | +| reason | ENUM('deleted_staff') NOT NULL | the one reason there is | +| reserved_at | DATETIME DEFAULT CURRENT_TIMESTAMP | | + +A deleted **staff** account's username is written here in the same transaction that deletes the row +(§6.6). There is **no release path**. `users.model` refuses a reserved name in `createUser` and in a +username `update`, and `isDuplicateUsername` reports that refusal as a duplicate, so registration, an +invite's acceptance, SSO provisioning, an admin creating or renaming an account and a self-service +change all answer it as a taken name. A player's username is never added and is free at once. + ### email_verifications *(engagement Phase 1b)* Same shape as `password_resets`, deliberately — an opaque random token whose **sha256 only** is stored, single-use, ~24h. | col | type | notes | @@ -1427,6 +1440,7 @@ their own router level, and `/sso/:provider/link` carries `requireAuth` per rout | GET | `/me/account` | cookie / bearer | — | full self account (`id, username, role, email, email_verified, email_pending, status, totp_enabled, has_password`) | | PATCH | `/me/account/username` | cookie / bearer (rate-limited) | `{username}` | change own username; re-issues the caller's session | | PATCH | `/me/account/password` | cookie / bearer (rate-limited) | `{newPassword, currentPassword?}` | change/set own password (current required unless the account has none); revokes other sessions, keeps the caller's | +| DELETE | `/me/account` | cookie / bearer (backoff + slow-down + rate-limited) | `{username, password, code?, recoveryCode?}` | **delete the caller's own account at once** (§6.6). The typed `username` must match (case-insensitive); the password is always required (AD7); a 2FA or recovery code when 2FA is on. Refusals carry a `code`: **400** `username_mismatch` · `wrong_password` · `code_required` · `wrong_code` (a wrong password or code is **400, never 401**, AD12, so a native client's refresh-on-401 never replays it; both count as a failed login for the backoff and bot score), **409** `password_required` (no password yet) · `last_admin`, **403** `DEMO_LOCKED` for the demo's shared logins. **200** `{ deleted: true }` and this browser's session and trust cookies cleared | | POST | `/me/account/totp/setup` · `…/enable` · `…/disable` | cookie / bearer | `{code}` on enable/disable | self 2FA enrollment (disable needs a valid current code, not a password). **enable** returns the one-time `recoveryCodes`; **disable** clears the user's trusted devices + recovery codes | | PATCH | `/me/account/email` | cookie / bearer (rate-limited, **password step-up**) | `{email, currentPassword?}` | request an address. **Stages it in `email_pending`; `email` is untouched**, so the account keeps receiving password-reset mail at the address it already has until the emailed link is opened — a typo cannot redirect account recovery. `currentPassword` is required when the account has one (an address is where recovery lands); an SSO-provisioned account with a null hash is exempt, the same carve-out `/me/account/password` makes. Returns `{email_pending, emailed, reason}` — `emailed:false` is reported honestly rather than pretending, because the caller typed this address themselves and there is no enumeration reason to hide it. **429** past the per-user send ceiling | | POST | `/me/account/email/resend` | cookie / bearer (rate-limited) | — | re-send the link for the staged address; **400** when nothing is pending | @@ -1683,7 +1697,7 @@ file a route sits in — that is the property the route manifest freezes. | DELETE | `/settings/:key` | reset one setting to its default by deleting the row. Allowlisted to the keys whose default lives outside the store (`theme_visual`, `brand_assets`, `nav_public`, `nav_admin`, `nav_player`, `hero_layout_draft`) — anything else is `400`. Idempotent: resetting a key that was never set succeeds | | POST | `/settings/brand-asset/:slot` | upload one brand asset (`logo` · `hero` · `favicon`) **and** point `brand_assets` at it, in one call → `{ url, brand_assets }`. One call rather than "upload, then PUT" so a half-completed save never leaves an unreferenced file in `/uploads`. Uses the shared `imageUpload.js` multer config — the mimetype allowlist is never widened, only tightened per slot: favicons are **PNG only** (§4.10 of [THEMING_AND_NAV.md](THEMING_AND_NAV.md)) and capped at 512 KB, logos at 1 MB, heroes at the shared 8 MB. A refused file is unlinked before the response. Merges into the existing overrides, so uploading a logo never clears a hero. `adminOnly` — tighter than the generic `POST /admin/uploads`, which editors may reach | | GET | `/activity?limit=&offset=` | paginated activity log | -| GET | `/users` · POST `/users` · PUT `/users/:id` · DELETE `/users/:id` | user mgmt (can't delete self / last admin; password hashed on write) | +| GET | `/users` · POST `/users` · PUT `/users/:id` · DELETE `/users/:id` | user mgmt (can't delete self / last admin; password hashed on write). DELETE runs the same deletion procedure as a member's own (§6.6): module unlink hooks, a staff name reserved, the owner mailed; audited `user.delete` | | GET | `/users/:id/trusted-devices` | list a user's active trusted devices (never tokens) | | DELETE | `/users/:id/trusted-devices` · `…/:deviceId` | revoke all / one of a user's trusted devices (logs `admin.trusted_device.revoke[_all]`) | | POST | `/users/:id/mfa/reset` | recover a locked-out user: disable TOTP + revoke all trusted devices + clear recovery codes (logs `admin.user.totp.reset`) | @@ -1823,7 +1837,8 @@ it. auth check. Reads are never locked. Matching follows express 4's defaults: case-insensitive, and the trailing slash optional. A few rows are conditional: - the target of `/admin/users/:id…` is a demo account; - - the caller of `/auth/me/…` is signed in as one; + - the caller of `/auth/me/…` is signed in as one, which includes `DELETE /auth/me/account` + (ACCOUNT_DELETION.md §4.3; the deletion service refuses a shared login too, behind the lock); - the `PUT /admin/settings` body names a protected key (`site_mode*`, `module_source_hosts`, `teams_voice_*`, `demo_*`, `hero_layout`). `hero_layout_draft` stays open, so the hero editor previews but cannot publish (DM21). @@ -1923,6 +1938,56 @@ routes, at SSE subscribe time and at the nav. It used to be documented here as c [`../modules/uo/API.md`](../modules/uo/API.md) §4 when Phase 4 closed, with the admin-facing guide still at [`SHARD_VISIBILITY.md`](SHARD_VISIBILITY.md). +### 6.6 Deleting an account + +Design of record: [`ACCOUNT_DELETION.md`](ACCOUNT_DELETION.md). One procedure, +`server/src/auth/accountDeletion.js` `deleteAccount(userId, { by, req })`, serves both routes: a member's +own `DELETE /auth/me/account` (`by: 'self'`) and staff's `DELETE /admin/users/:id` (`by: 'staff'`). Each +route makes its own checks first (the member's proof; an admin cannot delete themself), and the service +refuses the **last admin** and, on the demo, the **shared logins**. Then, in order: + +1. **Before the delete.** Read the address, username and role. Run every `started` module's + `onUserDeleting({ userId, deletedBy })` in turn (MODULE_API 1.20.0), each with a **10 s** limit. + This is where game links are unlinked through each module's own unlink (AD10). A hook that throws + or times out is logged and the deletion goes ahead (AD11). +2. **One transaction** (`utils/db.js` `transaction()`): a staff username goes into `reserved_usernames` + (AD9), and the `users` row is deleted. The foreign keys do the rest: credentials, sessions, devices, + identities, notification settings and the inbox **cascade**; authored content, reports, appeals, + Team activity, event participation, send logs and the activity log are **detached** (`SET NULL`). +3. **After commit**, fire-and-forget and each in its own `try`: a Teams reconcile (`reason: 'account + deleted'`); the audit entry `user.self_delete` (no user id, the request's IP) or `user.delete` (the + acting admin), whose `detail` names the deleted **id**, never the username; and the notice to the + old address, the protected template `auth.account-deleted`, when there was one and mail is + configured. + +**Sessions end at once** because `requireAuth` re-reads the user and answers 401 when the row is gone, +for a web cookie and a mobile access token alike. + +**Who sees a deleted account's name** (AD5, AD6). The snapshot columns (`team_forum_threads.created_username`, +`team_forum_posts.author_username`, `team_forum_uploads.uploader_username`, `content_reports.reporter_username` +/ `handled_username`, `team_forum_moderation.actor_username`, `team_forum_grants.*username`, +`team_leader_overrides.actor_username`, `team_moderation_requests.*_username`) are **never cleared**. A +snapshot whose id column is NULL is a deleted account, and `server/src/utils/displayAuthor.js` decides +what each viewer sees: members, Team leaders and visitors get **`[deleted player]`**; staff (admin, +editor, moderator) get the stored name and a `…Deleted` flag, which the admin screens render as +"Ravenna (deleted account)". The forum, the Reports queue, the leader's guest list and the Team digest +mail all go through it. A deleted account's forum grant is not listed as a guest at all (AD13). The +activity log keeps its rows and IP addresses for staff. + +**What goes and what stays**, as the screens and runicgateway.com `/delete-account/` say it: + +- **Goes:** the account, password, email address, two-factor secret and recovery codes; every session, + trusted device and push registration; linked Google, Discord, OIDC and Steam sign-ins; game-account + links (through the module's unlink) and what the module granted through them; notification settings + and the inbox. +- **Stays, shown to members as "[deleted player]":** what the member wrote where others read it, and + the reports and appeals they filed, as moderation records. +- **Stays, and staff can still see who it was:** the same records with the stored username, and the + activity log with its IP addresses, until the operator removes them. +- **Stays, detached:** event results (score and in-game name, no site account), the delivery log's + hashes and the suppression list. +- **A staff account's username** can never be used again. + --- ## 7. Email diff --git a/website/MODULE_API.md b/website/MODULE_API.md index 3a5a316..102ffb2 100644 --- a/website/MODULE_API.md +++ b/website/MODULE_API.md @@ -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 ``` diff --git a/website/TEAMS.md b/website/TEAMS.md index b6b2ec6..9453624 100644 --- a/website/TEAMS.md +++ b/website/TEAMS.md @@ -1020,7 +1020,12 @@ the CMS's sanitiser), stored sanitised, and served without re-sanitising — the already follow. Writes go through `ctx.middleware.rateLimit`-equivalent core middleware. `ON DELETE SET NULL` on authors rather than `CASCADE`: deleting a user must not silently blow holes in -a thread other people replied to. The post renders as "[deleted account]". +a thread other people replied to. The username snapshot is kept, and who sees it depends on the +reader (`ACCOUNT_DELETION.md` AD5, AD6; `server/src/utils/displayAuthor.js`): a member, a leader or +a guest sees **"[deleted player]"**, and staff see the stored name marked *(deleted account)*. The +same rule covers thread authors, uploaders, the Team digest mail, the grant and override records and +the Reports queue. A deleted account's grant stays in the ledger and drops off the leader's *Forum +guests* list, because a grant is matched by user id and there is none left (AD13). ### 5.3 Moderation stays separate from the site's moderation system