docs(website): deletion unlinks game accounts through each module's unlink

A cascade is not an unlink. For UO the link lives on the shard (the
site's shard_account_links is a mirror), so dropping the mirror alone
would leave the shard attributing the game account to a deleted user.

- AD10: the hook is onUserDeleting and runs before the delete; each
  module calls its own unlink for every link (Rust: links.unlinkOwned;
  UO: the sidecar's DELETE /link/{account}, as the staff unlink does).
- AD11: an unlink that can't finish never holds up the deletion. A shard
  that is offline queues into shard_pending_unlinks, retried on
  reconnect. A protected staff account (403) is listed for admins as
  "still linked on the shard".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-29 17:28:54 -05:00
parent 710262a591
commit fc3f0ed296
2 changed files with 78 additions and 24 deletions

View File

@@ -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 |

View File

@@ -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 <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.
- **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