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:
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user