From 2ab35334d6e98564fb3fe02808ee07cc2f18f2f4 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 29 Oct 2026 17:18:02 -0500 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- README.md | 3 +- android/PLAN.md | 13 +- website/ACCOUNT_DELETION.md | 268 ++++++++++++++++++++++++++++++++++++ website/USER_SAFETY.md | 7 +- 4 files changed, 282 insertions(+), 9 deletions(-) create mode 100644 website/ACCOUNT_DELETION.md diff --git a/README.md b/README.md index 924c5db..90843ed 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,8 @@ sidecar as a service, and hands you the values the website needs. | [SHARD_VISIBILITY.md](website/SHARD_VISIBILITY.md) | Who sees which shard data — the admin-configurable audience framework | | [TRUSTED_DEVICES_MFA.md](website/TRUSTED_DEVICES_MFA.md) | TOTP two-factor, trusted devices and recovery codes | | [STEAM_SIGNIN.md](website/STEAM_SIGNIN.md) | **Plan, built 2026-10-19** — Sign in with Steam as a built-in provider beside Google and Discord (OpenID 2.0), and a Steam id for modules | -| [USER_SAFETY.md](website/USER_SAFETY.md) | **Plan, 2026-10-29** — report, block and hide on the website and in the app: an account-wide block, a member's own hide and staff's hide for everyone, with module-owned report targets (`MODULE_API` 1.20.0) | +| [ACCOUNT_DELETION.md](website/ACCOUNT_DELETION.md) | **Plan, 2026-10-29**: a member deletes their own account on the website and in the app, as Google Play requires. One deletion service for staff and self, an `onUserDeleted` module hook (`MODULE_API` 1.20.0), and runicgateway.com's `/delete-account/` page as the address Play is given | +| [USER_SAFETY.md](website/USER_SAFETY.md) | **Plan, 2026-10-29** — report, block and hide on the website and in the app: an account-wide block, a member's own hide and staff's hide for everyone, with module-owned report targets (`MODULE_API` 1.21.0, after account deletion) | | [DEMO_MODE.md](website/DEMO_MODE.md) | **Design, fully decided 2026-10-23** — the public `demo.runicgateway.com`: one flag that locks destructive actions, static logins, and a wipe every couple of hours (website#222) | | [MODERATION_APPEALS.md](website/MODERATION_APPEALS.md) | Moderation actions, content reports and the appeals flow | | [SPAWN_ATLAS.md](website/SPAWN_ATLAS.md) | The bestiary / spawn atlas: what the shard contains, parsed from the shard's own ServUO files — served over the bridge since Protocol 8, so no shared filesystem | diff --git a/android/PLAN.md b/android/PLAN.md index 77a70cd..44d316a 100644 --- a/android/PLAN.md +++ b/android/PLAN.md @@ -1055,11 +1055,14 @@ push, and Play (M6–M8) follow the designed app. game-account creation), so Play requires a deletion path in the app and at a web address, and the platform had none: only an admin could delete an account. A member gets *Delete my account* on the website and in the app, which removes the account and its game links. - A runicgateway.com page explains it, and that page's address is the one Play is given. It - is planned as its own piece of work, before the cutover. - 3. **Report, block and hide** across `website`, `Module-Rust`, the kit and this app - (`USER_SAFETY.md` §7). - 4. **The cutover**, `edge` → `main`, once 1-3 have landed, so the first Play bundle carries + A runicgateway.com page explains it, and that page's address is the one Play is given. The plan is + [`website/ACCOUNT_DELETION.md`](../website/ACCOUNT_DELETION.md), step 3 below. + 3. **Account deletion** across `website`, the two modules, the kit, this app and + runicgateway.com (`../website/ACCOUNT_DELETION.md` §6). First, because Play requires it + and it takes `MODULE_API` 1.20.0 (org lead, 2026-10-29). + 4. **Report, block and hide** across `website`, `Module-Rust`, the kit and this app + (`USER_SAFETY.md` §7), now `MODULE_API` 1.21.0. + 5. **The cutover**, `edge` → `main`, once 1-4 have landed, so the first Play bundle carries all of it. **Before it merges, the org lead enrolls the existing keystore as the app-signing key** in Play Console (App integrity, "use my own key"). `PLAY_SERVICE_ACCOUNT_JSON` is already set, so the release run will call Play, and the signing choice is permanent. On the diff --git a/website/ACCOUNT_DELETION.md b/website/ACCOUNT_DELETION.md new file mode 100644 index 0000000..1bd194d --- /dev/null +++ b/website/ACCOUNT_DELETION.md @@ -0,0 +1,268 @@ +# 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. diff --git a/website/USER_SAFETY.md b/website/USER_SAFETY.md index cb9a8c9..7d2cd8f 100644 --- a/website/USER_SAFETY.md +++ b/website/USER_SAFETY.md @@ -1,7 +1,8 @@ # Report, block and hide — user safety on the website and in the app **Status:** plan of record, 2026-10-29. Nothing built yet. Each phase stops for the org lead -before the next one starts. +before the next one starts. Built after account deletion ([`ACCOUNT_DELETION.md`](ACCOUNT_DELETION.md)), +which is why it is `MODULE_API` 1.21.0. **Why now.** Google Play's content-rating questionnaire for the Android app (`android/PLAN.md` M8) was answered "users or user-generated content can be reported". The app shows other players' chat @@ -56,7 +57,7 @@ staff's. a module's content can be reported as (for Rust: `rust.chat`). Each type answers two questions for core: `snapshot(id)` (author, text, time, where it was said), and `hide(id, actor)` / `unhide(id, actor)`. Core files the report, stores the snapshot and runs the queue. The module owns - the content and its hidden flag. This is a `MODULE_API` minor bump, **1.20.0**. The Integration + the content and its hidden flag. This is a `MODULE_API` minor bump, **1.21.0**. The Integration Kit re-pins and gains a paragraph. - **One filing route for every target**: `POST /player/reports {targetType, targetId, reason, detail?}`. Forum types keep working through the existing forum route, which then calls the same @@ -119,7 +120,7 @@ Play declarations gain "user-generated content: collected". M21 says so. ## 7. Build order 1. **This plan** (docs), with §5's answers. -2. **Core** (`website`): §4.1, `MODULE_API` 1.20.0, tests, OpenAPI, `BACKEND_DESIGN.md`, +2. **Core** (`website`): §4.1, `MODULE_API` 1.21.0, tests, OpenAPI, `BACKEND_DESIGN.md`, `MODULE_API.md`, `TEAMS.md` §5.6 amended. One PR. 3. **Module-Rust** (`edge`): §4.2 plus the core-ref pin. One PR. **Integration-kit**: the re-pin and a paragraph on `reportTargets`. One PR.