Files
docs/website/ACCOUNT_DELETION.md
wtclaude 710262a591 docs(website): record the account-deletion answers (AD4-AD9)
The org lead answered the plan's six questions on 2026-10-29:
- delete at once, no grace period
- what a member wrote stays, and members see every snapshot of a
  deleted account as "[deleted player]"
- staff keep the name and the IP for moderation, so the stored rows are
  untouched and the rule is about who sees what (displayAuthor)
- the password is always the proof; SSO sits on top of a real account
- a UO game account stays on the shard; no protocol change
- a deleted staff username is never available again (reserved_usernames)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-29 17:23:58 -05:00

278 lines
18 KiB
Markdown

# 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-AD9). Nothing built
yet. 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
(`android/PLAN.md` M8, step 2). The app starts accounts in two places: the sign-in screen's link to
the deployment's sign-up page, and in-app game-account creation where a game server offers it
(`PlayerShardApi.createAccount`). The platform had no self-service deletion: only an admin could
delete an account. The org lead chose to build it (AD1) and to do it **before** report, block and
hide (`USER_SAFETY.md`), so it takes the next `MODULE_API` minor.
## 1. What a member can do
| Where | What they see | What happens |
|---|---|---|
| Website, *Account* (`PlayerAccount.jsx`) | a *Delete my account* section at the bottom: what goes, what stays (§4.1), and a confirm step | the account is deleted at once (AD4), the member is signed out everywhere, and the site says so |
| App, *Account* (`AccountScreen.kt`) | the same section and the same confirm step | the same, and the app clears what it holds on the phone, as a sign-out does |
| runicgateway.com `/delete-account/` | how to do it on the website or in the app, what is deleted and what is kept, and who to ask when you cannot sign in | nothing; it is the page whose address Play is given (AD2) |
The confirm step asks for the account's password, and for a two-factor code when the account has
two-factor sign-in on. It also asks the member to type their username, so a mis-tap on a phone
cannot delete an account. Signing in with Google, Discord or Steam is a convenience on top of an
account that already has its own password (AD7), so the password is always the proof.
Every member can delete themself, staff included. The one refusal is the **last admin**, as the
admin route already refuses. Then a deployment would be left with no one who can run it.
## 2. Decisions (org lead, 2026-10-29)
- **AD1 — Build self-service deletion.** The org lead chose this over an explanation page alone,
and over removing the app's account-creation links. A member deletes their account on the
website and in the app, and that removes the account and its game links.
- **AD2 — runicgateway.com hosts the deletion page Play is given.** We publish the app, so
Play needs one address from us. Every deployment runs its own site, so that page explains the
steps rather than performing them.
- **AD3 — Before report, block and hide.** It takes `MODULE_API` **1.20.0**, and
`USER_SAFETY.md`'s `reportTargets` moves to 1.21.0.
The org lead's answers to the six questions in §5, the same day:
- **AD4 — Delete now** (Q1). There is no grace period. The typed username and the password are
the safeguard.
- **AD5 — What they wrote stays, attributed to "[deleted player]"** (Q2). For members, every
username snapshot of a deleted account reads "[deleted player]": their threads, posts and
uploads, and the records in gap 2. That label replaces today's "[deleted account]".
- **AD6 — Staff keep the name and the IP** (Q3). The snapshots and the activity log's IP
addresses stay in the database, and the staff views keep showing them, marked as a deleted
account, for moderation. So AD5 is a rule about what members see, not a change to the stored
rows.
- **AD7 — The password is the proof** (Q4). SSO is a convenience. A member must already have a
proper account before they can link a sign-in provider, so every account has its own password,
and the confirm step always asks for it. An account that somehow has no password is told to set
one first, on the same Account page.
- **AD8 — A UO game account stays on the shard** (Q5). Only the link goes. The shard's staff
handle the shard account like any other. There is no protocol change.
- **AD9 — A deleted staff account's username is never available again** (Q6). It can never be
registered, or taken by a username change. A player's username is free at once.
## 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,
then `users.remove(id)`, a plain `DELETE`. Everything else is the schema's foreign keys.
- **What the foreign keys already do.** Of the 59 keys that reference `users(id)`:
- **CASCADE** removes what belongs to the account alone: linked sign-in identities, mobile
refresh tokens and SSO codes, revoked sessions, trusted devices, recovery codes, password
resets, email verifications, push devices, notification subscriptions and channel prefs,
in-app notifications, and the engagement outbox, cooldowns and digest state.
- **SET NULL** detaches what the member made from them: news posts, wiki edits, forum threads,
posts and uploads, reports filed, appeals, Team activity, event participation, engagement
send logs, and the activity log.
- Modules: Module-uo's `shard_account_links`, and Module-Rust's `rust_account_links`,
`rust_perm_group_members`, `rust_perm_grants`, `rust_perm_run_grants` and
`rust_permgroup_members`, all **CASCADE**. Module-Rust's grants and group memberships keyed
straight to a Steam id (`rust_perm_steam_grants`, `rust_permgroup_steam_members`) have no
site account behind them, and stay. Staff gave them to a player in the game, not to a member
of the site.
- **Sessions die at once.** `requireAuth` re-reads the user on every request and answers 401 when
the row is gone (`session.middleware.js`). So a deleted member's web cookie or mobile access
token stops working on its next use, without waiting for it to expire.
- **Rust permissions are withdrawn in the game by themselves.** `permSync.js` compares a digest of
the desired set with what each server last confirmed. When the grant rows cascade away, the digest
changes, and the next tick pushes the set with the withdrawn grants.
- **Teams follow on the next reconcile.** `team_members.user_id` is SET NULL. The roster belongs to
the module and re-syncs every 15 minutes (`TEAMS.md`).
**The gaps:**
1. There is no self-service route, and no screen on the web or in the app.
2. **Their username survives in snapshot columns.** The Teams tables store a username next to the
user id, deliberately, so a record still names someone after the account is gone.
`teamForum.model.js` shows "[deleted account]" only when the snapshot is empty too. There are
two kinds:
- **What they wrote:** `team_forum_threads.created_username`,
`team_forum_posts.author_username`, `team_forum_uploads.uploader_username`.
- **Records of what was done:**
- `content_reports.reporter_username` and `handled_username`
- `team_forum_moderation.actor_username`
- `team_forum_grants.username`, `granted_username` and `revoked_username`
- `team_leader_overrides.actor_username`
- `team_moderation_requests.requested_username` and `decided_username`
3. **The activity log keeps IP addresses.** Rows for the deleted member keep `ip`, and `detail`
can carry a username, with only `user_id` cleared. They stay, for staff (AD6).
4. **A module cannot learn that a user was deleted.** If a module keeps user data without a
foreign key, or has a side effect in the game, it has no hook. The Module API has
`onIdentityLinked`, but nothing for deletion.
5. **A game account the app created stays on the game server.** For UO the link cascades, but the
shard account and its characters remain, which is intended (AD8).
6. The demo's four accounts (`demo/mode.js`) must not be deletable.
7. **Nothing stops a deleted name being reused.** Registration and username changes only check
the live `users` table (AD9).
## 4. Design
### 4.1 Core (`website`)
- **One service, two routes.** A new `accountDeletion.deleteAccount(userId, { req, by })` holds
the whole procedure. The admin route's `users.remove(id)` call is replaced by it, so a deletion
by staff and a deletion by the member do the same thing.
- **The member's route.** `DELETE /api/v1/auth/me/account`, behind `requireAuth` and
`accountChangeLimiter`.
- Body: `{ username, password, code?, recoveryCode? }`.
- 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
- 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
- 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.
- **The procedure, in order:**
1. **Before the delete**, read what is needed afterwards: the email address (for the notice
below), the username and the role.
2. **In one transaction:**
- when the role is staff (anything but `player`), add the username to a new
`reserved_usernames` table (AD9)
- delete the `users` row, so the foreign keys do the rest. The snapshot columns, the
content and the activity log are **not** touched (AD5, AD6).
3. **After commit**, fire-and-forget, each in its own `try`:
- every module's `onUserDeleted` hook (below)
- `teams.reconcile({ reason: 'account deleted' })`
- an activity-log entry, `user.delete` (by staff) or `user.self_delete`, naming the id and
never the username
- an email to the address read in step 1, when there was one and mail is configured:
"your account on <site> was deleted on <date>". This is the only notice a person gets
if someone else used their unlocked phone, and it says who to contact.
- **Nothing can stop a deletion once it starts.** A module hook that throws is logged and the
deletion still stands, because Play requires the request to be honoured. That is also why the
hook runs after the commit rather than before it.
- **Who sees the name (AD5, AD6).** A snapshot whose id column is now NULL belongs to a deleted
account; nothing else clears those ids. Rendering follows the viewer:
- **Members**, Team leaders included, and signed-out visitors see `[deleted player]`. That
covers forum threads, posts and uploads, and any record a member can read. The constant in
`teamForum.model.js` and `contentReports.model.js` changes from `[deleted account]`, and the
"id NULL" case now wins over a present snapshot.
- **Staff** (admin, editor, moderator) see the stored username with a *deleted account*
marker, in the Reports queue, the forum's moderation views, the user and activity screens,
and Team moderation requests. The activity log keeps its IP addresses.
- The rule lives in one helper, `displayAuthor(row, viewer)`, which every model that renders a
snapshot uses, and its tests cover each table in gap 2.
- **`reserved_usernames`** (AD9) holds `username_norm` (the primary key, lowercased), `reason`
(`deleted_staff`) and `reserved_at`. Registration, an invite's acceptance, an admin creating an
account, and a username change all refuse a reserved name, with the same message as a taken
one. There is no release path, because the org lead said "completely unavailable". A player's
username is not added and is free at once.
- **`MODULE_API` 1.20.0: `api.onUserDeleted(async ({ userId, deletedBy }) => {})`**, where
`deletedBy` is `'self' | 'staff'`.
- It runs after the row is gone, so a module that keeps data without a foreign key cleans it
up, and a module with game-side effects acts on them at once. It must not throw.
- It is the same staging and validation as `onIdentityLinked`.
- `MODULE_API.md` §2.4 and §2.5 are amended. The Integration Kit's `coreApi` check goes red
until it is re-pinned, which is how the kit is meant to notice.
- **What goes and what stays** is written once in `BACKEND_DESIGN.md`, and the screens and the
runicgateway.com page say the same in plain words:
- **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 and, in Rust, the permissions and groups the site gave those Steam ids;
notification settings and the inbox.
- **Stays, shown to other members as "[deleted player]":** what you wrote where others read it
(AD5), and reports you filed and appeals you made, as moderation records.
- **Stays, and staff can still see who it was:** the same records with your username, and the
activity log with its IP addresses, for moderation (AD6). Kept until the operator removes
them.
- **Stays, detached:** event results (the score and the in-game name, no site account), the
delivery log's hashes and the suppression list (`collection.mjs` already says so).
- **If you were staff:** your username can never be used again (AD9).
- **Docs:** `BACKEND_DESIGN.md` (the route, the procedure, `reserved_usernames`, the
display rule, the list above), `TEAMS.md` (the forum's deleted-author rule), `MODULE_API.md`,
OpenAPI.
### 4.2 Website client
- **`PlayerAccount.jsx`** gets a *Delete my account* section, last on the page and visually set
apart. It holds the "what goes, what stays" text and a button that opens a confirm dialog with
the username, password and (if needed) code fields. *Delete* stays disabled until the username
matches.
- **On success**, the client clears its session state and lands on the home page with a notice:
"Your account has been deleted."
- **On the demo** the section shows, and its button is disabled with the demo's usual locked
wording, so a reviewer can see the feature exists.
### 4.3 Android app (`edge`)
- **`AccountScreen.kt`** gets a *Delete my account* row that opens a new screen with the same text
and fields, and `MeApi.deleteAccount(body)`.
- **On success** the app does what a sign-out does, without the logout call, because the server
has already dropped everything. It clears the tokens, the trusted-device token, the push
registration and the inbox snapshot, then returns to sign-in with "Your account has been
deleted."
- **On the demo** the server refuses, and `demoLockInterceptor` shows the usual locked message.
The row is still visible, which is what Play's reviewer needs to find.
### 4.4 Modules
- **Module-Rust (`edge`):**
- `onUserDeleted` marks the permission sync dirty, so withdrawn grants reach the game on the
next tick rather than waiting for the digest check.
- The links and grants themselves already cascade.
- Core-ref pin.
- **Module-uo:**
- `shard_account_links` already cascades, and the shard account stays (AD8), so the module
registers no hook.
- Core-ref pin only, if its `coreApi` range needs it.
- **Integration Kit:** the re-pin, and a paragraph in the module chapter: what `onUserDeleted` is
for, and that a module keeping user data with no foreign key must use it.
### 4.5 runicgateway.com
- **`/delete-account/`**, the page Play is given (AD2). It covers:
- the app's name and ours
- the steps on the website and in the app
- what is deleted and what is kept, and for how long: the moderation records that stay, and
that other members see "[deleted player]"
- that we hold no account, because each community runs its own site
- what to do if you cannot sign in: ask that community's operator, who can delete the account
from the admin panel
- **The data, written once.** The page's facts become `deployment` rows in `collection.mjs`, so
`/privacy` and the page agree. `PLAY_DATA_SAFETY.md`'s account-deletion bullet then names the URL
and answers "yes" to "can users request deletion".
## 5. The questions, as they were asked and answered (org lead, 2026-10-29)
| | Asked, with the example given | Answer |
|---|---|---|
| Q1 | Delete now, or lock for 14 days and let a sign-in cancel it? (a player taps *Delete* at 2 a.m. after a bad raid) | **Delete now** (AD4) |
| Q2 | Keep what they wrote, anonymously, or delete it? (a forum guide with 40 replies) | **Keep it; every username snapshot reads "[deleted player]"** (AD5) |
| Q3 | Keep or clear the activity log's IPs and the moderation snapshots? (a banned player returns under a new name) | **Keep the name and the IP on the admin side, for moderation** (AD6) |
| Q4 | How does an account with no password confirm? (a Discord-only player) | **Every account already has a password; SSO is a convenience on top of a proper account** (AD7) |
| Q5 | What happens to a UO game account the app created? (`Thorne`, with three characters) | **Leave it on the shard** (AD8) |
| Q6 | Can a deleted username be taken at once? (a moderator `Ravenna` impersonated the next day) | **Staff names are completely unavailable** (AD9) |
## 6. Build order
1. **This plan** (docs), with the answers (AD4-AD9).
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.
3. **Module-Rust** (`edge`): §4.4's hook plus its core-ref pin. One PR. **Module-uo** gets a
pin only if its `coreApi` range needs one.
**Integration Kit**: the re-pin and the paragraph, one PR.
4. **Android** (`edge`): §4.3. One PR.
5. **runicgateway.com**: §4.5, plus `PLAY_DATA_SAFETY.md` regenerated. One PR.
6. **A walk**, using a throwaway account on each surface:
- delete on the website, and in the app on the emulator
- the other device's session dies on its next request
- a Rust-linked account's grants leave the rig on the next sync
- a forum post shows "[deleted player]" to a member and the old username, marked deleted, to
staff
- the deleted staff username is refused at registration and at a username change
- the demo refuses
- the last admin is refused
7. Then `USER_SAFETY.md` (report, block and hide, now 1.21.0), then `android/PLAN.md` M8's
cutover.