Files
docs/website/ACCOUNT_DELETION.md
wtclaude 2ab35334d6 docs(website): plan self-service account deletion
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
2026-10-29 17:18:02 -05:00

17 KiB

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 was deleted on ". 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.