ACCOUNT_DELETION.md is the plan of record for the org lead's decision: a member deletes their own account on the website and in the app, as Google Play requires of an app that starts accounts. - One deletion service for the admin route and the member's own route, with a confirm step, the last-admin and demo refusals, clearing the username snapshots on what the member wrote, and a notice by email. - MODULE_API 1.20.0: api.onUserDeleted, which runs after the commit and can't stop a deletion. - Screens on the web and in the app; runicgateway.com /delete-account/ as the address Play is given. - What the schema already does (59 keys: credentials cascade, content detaches), the gaps, six questions, and the build order. M8's order puts account deletion before report, block and hide, which moves USER_SAFETY.md's reportTargets to MODULE_API 1.21.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
269 lines
17 KiB
Markdown
269 lines
17 KiB
Markdown
# Account deletion — a member deletes their own account, on the website and in the app
|
|
|
|
**Status:** plan of record, 2026-10-29. Nothing built yet. §5 holds the questions for the org lead;
|
|
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 (or scheduled, §5 Q1), 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. An account with neither is §5 Q4. It also asks the member to type their
|
|
username, so a mis-tap on a phone cannot delete an account.
|
|
|
|
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.
|
|
|
|
## 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 (§5 Q3).
|
|
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 (§5 Q5).
|
|
6. The demo's four accounts (`demo/mode.js`) must not be deletable.
|
|
|
|
## 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 checked when the account has one.
|
|
- 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 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) and the linked game ids.
|
|
2. **In one transaction:**
|
|
- clear the "what they wrote" snapshots (gap 2), so their threads, posts and uploads show
|
|
"[deleted account]"; §5 Q2 decides whether the content itself stays
|
|
- apply §5 Q3's answer to the "records" snapshots and to their activity-log rows
|
|
- delete the `users` row, so the foreign keys do the rest
|
|
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.
|
|
- **`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, without your name:** what you wrote where others read it (§5 Q2), shown as
|
|
"[deleted account]"; reports you filed and appeals you made, as moderation records; event
|
|
results, with the score kept and the name removed; the delivery log's hashes and the
|
|
suppression list (`collection.mjs` already says so).
|
|
- **Stays for security:** §5 Q3's answer.
|
|
- **Docs:** `BACKEND_DESIGN.md` (route, procedure, the table above), `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.
|
|
- `onUserDeleted` does whatever §5 Q5 decides for the shard account.
|
|
- Core-ref pin.
|
|
- **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
|
|
- 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. Questions for the org lead
|
|
|
|
- **Q1 — Delete now, or after a grace period?**
|
|
- *Example:* a player taps *Delete* at 2 a.m. after a bad raid.
|
|
- **Now:** the account is gone, and the username is free to register by morning.
|
|
- **Grace period (14 days):** the account is locked at once and signs them out everywhere.
|
|
Signing in during the 14 days cancels the deletion, and a nightly sweep deletes it when the
|
|
period ends. That needs a `pending_deletion` status and a sweep.
|
|
- *Recommended: now.* It is simpler, it is what the confirm step is for, and Play accepts either.
|
|
- **Q2 — What happens to what they wrote?**
|
|
- *Example:* a member wrote a forum guide with 40 replies under it.
|
|
- **Keep it, shown as "[deleted account]":** the thread still reads, with no name on it.
|
|
- **Delete their posts and uploads:** the guide is gone, and the replies answer nothing.
|
|
- *Recommended: keep it, anonymous.* Most forums work this way, and the screens say so before
|
|
the member confirms.
|
|
- **Q3 — Records of what was done: the activity log's IP addresses, and the moderation
|
|
snapshots.**
|
|
- *Example:* a banned player deletes their account and comes back as someone new. Or a Team
|
|
leader who hid a post deletes their account, and staff later ask who hid it.
|
|
- **Keep them as they are:** moderators can still match the old IPs, and a record still says
|
|
who filed the report or hid the post. Those are security and moderation records, and the
|
|
privacy notes say so.
|
|
- **Clear them:** clear `ip` and the username in `detail` in the activity log, and the
|
|
"records" snapshots in gap 2. Nothing can be matched any more, and those records then read
|
|
"[deleted account]".
|
|
- *Recommended: keep them,* as security and moderation records kept until the operator removes
|
|
them, which is what `collection.mjs` already says about the audit log.
|
|
- **Q4 — How does an account with no password confirm?**
|
|
- *Example:* a player who only ever signs in with Discord, with no two-factor sign-in.
|
|
- **Typing the username on a signed-in session is enough:** simple, but a person holding their
|
|
unlocked phone can do it.
|
|
- **Sign in with the provider again first** (within the last 10 minutes): safer, but it needs a
|
|
re-authentication flow on the web and in the app.
|
|
- *Recommended: sign in again,* reusing the SSO flow with a `reauth` purpose.
|
|
- **Q5 — The UO game account the app can create.**
|
|
- *Example:* a player made `Thorne` on the shard from the app and has three characters on it.
|
|
- **Leave it on the shard:** the link goes, and the shard account and characters stay. The
|
|
shard's staff deal with it as they would any account.
|
|
- **Offer to delete it too:** the confirm step adds "Also delete game account Thorne and its
|
|
characters". That needs a new bridge command, `account.delete`, and a protocol bump in
|
|
servuo-plugins, link and module-uo.
|
|
- *Recommended: leave it.* Play's rule concerns the account a user signs in to the app with,
|
|
which is the site account, and a shard account belongs to the shard. If Play's review asks,
|
|
the second option can follow.
|
|
- **Q6 — Can someone else take the username straight away?**
|
|
- *Example:* a moderator called `Ravenna` deletes their account, and the next day someone
|
|
registers `Ravenna` and messages players as staff.
|
|
- **Free at once:** simple.
|
|
- **Reserved for 90 days:** a small `reserved_usernames` table that registration and username
|
|
changes check.
|
|
- *Recommended: reserve for 90 days*, for staff accounts only.
|
|
|
|
## 6. Build order
|
|
|
|
1. **This plan** (docs), with §5's answers.
|
|
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), OpenAPI, `BACKEND_DESIGN.md`,
|
|
`MODULE_API.md`. One PR.
|
|
3. **Module-Rust** (`edge`) and **Module-uo**: §4.4 plus their core-ref pins, one PR each.
|
|
**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 account]"
|
|
- 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.
|