diff --git a/README.md b/README.md index 90843ed..74a70d1 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ 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 | -| [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 | +| [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 `onUserDeleting` module hook that unlinks game accounts through each module's own unlink (`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 | diff --git a/website/ACCOUNT_DELETION.md b/website/ACCOUNT_DELETION.md index fef2808..9ea1263 100644 --- a/website/ACCOUNT_DELETION.md +++ b/website/ACCOUNT_DELETION.md @@ -1,6 +1,6 @@ # 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 +**Status:** plan of record, 2026-10-29, with every question answered (AD4-AD11). 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 @@ -58,6 +58,21 @@ The org lead's answers to the six questions in §5, the same day: - **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. +Asked after the plan was first written ("will deletion call the unlink function for that +player?"), the same day: + +- **AD10 — Deletion unlinks through each module's own unlink, before the row goes.** A database + cascade is not an unlink. For UO, the link lives on the shard (gap 8), so dropping the site's + mirror would leave the shard attributing the game account to a user who no longer exists. The + hook is therefore `onUserDeleting` and runs before the delete. Each module calls the unlink it + already has, for every link the person holds, so the game sees exactly what a player's own + unlink produces. +- **AD11 — An unlink that cannot finish never holds up the deletion.** + - A shard that is offline (503) gets a pending unlink in the module, retried when the bridge + reconnects. + - A **protected staff account**, which the shard refuses to unlink (403), is logged and shown to + admins as *still linked on the shard*, for the shard's staff to clear by hand. + ## 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, @@ -75,7 +90,7 @@ The org lead's answers to the six questions in §5, the same day: `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. + of the site. **A cascade is not an unlink, though** (gap 8, AD10). - **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. @@ -109,6 +124,17 @@ The org lead's answers to the six questions in §5, the same day: 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). +8. **A cascade skips the modules' unlink, and for UO that leaves the shard linked.** + - **UO:** `shard_account_links` is only a mirror. The shard keeps the site user id on the game + account (`INTEGRATION.md`: "permanently tagged with your `websiteUserId`"), and every + `mob.login` reports it as `webId`. That is why the staff unlink in `usersShard.controller.js` + calls the sidecar's `DELETE /link/{account}` first and drops the mirror after. A cascade + alone would leave the shard, and the sidecar's own link mirror, attributing the account to a + deleted user forever, with every login still carrying that `webId`. + - **Rust:** the site's `rust_account_links` is the only record; the plugin keeps no link state. + The cascade gets the same result as `links.unlinkOwned`, apart from its + `rust.account.unlink` log entry and its prompt Teams re-sync. Calling the real function keeps + both. ## 4. Design @@ -134,23 +160,24 @@ The org lead's answers to the six questions in §5, the same day: - `#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. + below), the username and the role. Then run every module's `onUserDeleting` hook (below), in + turn, each in its own `try`. That is where game links are unlinked (AD10). 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 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. + - **Nothing can stop a deletion once it starts.** A module hook that throws, or that takes too + long, is logged, and the deletion goes ahead, because Play requires the request to be + honoured. Core gives each hook a time limit (10 seconds) so a hung game server cannot hold a + member's request open; whatever the module had not finished is its pending work (AD11). - **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 @@ -167,11 +194,16 @@ The org lead's answers to the six questions in §5, the same day: 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 +- **`MODULE_API` 1.20.0: `api.onUserDeleting(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`. + - It runs **before** the row goes, while the module can still read the person's links (AD10). + Its job is to undo in the game, and anywhere else the module holds state, what the site's + rows are about to stop saying: unlink each game link through the module's own unlink, and + clean up data kept without a foreign key. + - It cannot veto. Throwing, or running past core's time limit, is logged, and the deletion + goes ahead (AD11). A module that cannot finish keeps its own record of what is left, and + retries. + - It is the same staging and validation as `onIdentityLinking`. - `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 @@ -217,16 +249,34 @@ The org lead's answers to the six questions in §5, the same day: ### 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. + - `onUserDeleting` calls `links.unlinkOwned(steamId, userId)` for each Steam id the person + holds, the same call the player's own unlink makes. That keeps its `rust.account.unlink` + entry (with `detail.reason: 'account deleted'`) and its Teams re-sync. + - It then marks the permission sync dirty, so withdrawn grants reach the game on the next tick + rather than waiting for the digest check. + - It never fails on the game: the link is the site's own record and the plugin keeps none, so + there is nothing pending. - 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. + - `onUserDeleting` calls the staff unlink's path for each game account in + `shard_account_links`: `uoLinkClient.unlinkAccount({ actor, account })`, where `actor` is + `web:account-deletion`, then `shardLinks.removeByAccount(account)` on success. That clears + the shard's `WebsiteUserId` tag, so attribution stops at once. The shard account and its + characters stay (AD8). + - **Shard offline** (503, or no sidecar configured): the account goes into a new + `shard_pending_unlinks` table (`account`, `queued_at`, `attempts`, `last_error`), and the + mirror row goes with the cascade. The module retries the queue at boot and whenever the + bridge reconnects, until the shard answers 200 or 404 (404 means the shard has nothing to + clear). + - **Protected staff account** (403): the shard refuses by design, so retrying will not help. + The account goes into the same table marked `refused`. Admin → Shard shows it as *still + linked on the shard* until shard staff clear it by hand, and the module drops the row once + the shard stops reporting that `webId`. + - Core-ref pin. +- **Integration Kit:** the re-pin, and a paragraph in the module chapter. `onUserDeleting` is + where a module unlinks anything a game holds for the person, through its own unlink, and cleans + up data kept with no foreign key. It can't stop the deletion, so a module that talks to a game + needs a retry for when the game is away. ### 4.5 runicgateway.com @@ -259,15 +309,19 @@ The org lead's answers to the six questions in §5, the same day: 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. +3. **Module-Rust** (`edge`) and **Module-uo**: §4.4's hooks plus their core-ref pins, one PR + each. Module-uo's PR adds `shard_pending_unlinks`, the retry, and the *still linked on the + shard* list. **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 Rust-linked account's grants leave the rig on the next sync, with a + `rust.account.unlink` entry + - a UO-linked account (the local ServUO with the uo-link sidecar): the shard's link is cleared, + and the next login carries no `webId`. With the sidecar stopped, the unlink waits in + `shard_pending_unlinks` and clears when the sidecar comes back - 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