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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user