feat(legal): /delete-account/, the page Google Play is given (D85, D86) #45

Merged
whitlocktech merged 2 commits from feat/delete-account-page into main 2026-10-30 01:42:56 -05:00
11 changed files with 621 additions and 36 deletions

30
PLAN.md
View File

@@ -264,6 +264,7 @@ somewhere other than the thing it decided. The count of record is **eighty-four*
| D80–D81 | §16, "How phase 18 built it" | A name in a screenshot is a role; the Rust captures' players are mock data |
| D82–D83 | §15, "How the site reaches it" | `/beta` lists the demo's sign-ins as the demo publishes them; a demo deep link names its game and shows only for the games the mounted `demoGames` lists |
| D84 | §9, "How phase 6 built the legal pages" | The Data Safety answers say "collected, not shared" for everything the app sends, amending D33 |
| D85–D86 | §9, "`/delete-account/`" | The deletion page is linked from the footer, `/privacy` and `/app/`; someone who cannot sign in is sent to their community's operator, never to us |
---
@@ -1086,6 +1087,34 @@ form's security questions. D33's test now asserts the part of the premise that i
**nothing is shared**. `/privacy` section 2 keeps saying none of it reaches us, and `lastUpdated`
moved with it. Report, block and hide (Android M8 step 3) adds its rows when it lands.
### `/delete-account/` (2026-10-30)
Play needs one account-deletion address from whoever publishes the app. The platform gained
self-service deletion on the website and in the app (`docs/website/ACCOUNT_DELETION.md`, AD1), and
this page is the address Play is given (AD2, §4.5 there). It cannot delete anything: we hold no
account, so it explains where the button is in the app and on the website, what goes and what stays
and for how long, and who to ask when you cannot sign in.
**The facts are one row.** What a deletion takes is the `deploy-account-deletion` row in
`src/data/collection.mjs`, whose `deletion` field holds the website's own words for what goes and
what stays (`client/src/lib/accountDeletion.js`, which the app's strings repeat) plus how long each
kept thing is kept, which Play asks and the screens do not say. `/privacy` section 3 renders the row
as prose, the page renders its lists, and `PLAY_DATA_SAFETY.md` prints them under the console's
deletion questions, so the three cannot disagree. `deletionRow()` throws when the row is missing,
and `test/legal.test.mjs` fails a kept entry with no retention. The app rows that deletion touches
(the session, the trust token, push and the inbox snapshot) say so, and `lastUpdated` moved.
**D85 — The page is linked from the footer, `/privacy` and `/app/`** (org lead, 2026-10-30). The
footer's legal bar reads Privacy · Terms · Delete account, because a player looking for it looks
where the privacy link is; `/privacy` sections 2 and 3 and the bottom of `/app/`'s "Two ways to get
it" link to it as well.
**D86 — Someone who cannot sign in is sent to their community's operator, never to us** (org lead,
2026-10-30). The page offers "Forgot your password?" first, which works when the account has an
email address and the site can send mail, and then the operator, who can delete any account from the
admin panel. It says plainly that we cannot do it: we hold no account and have no access to any
community's site. Offering our contact address to help find an operator was considered and declined.
**What phase 6 also closed.** `/privacy/` and `/terms/` were the last two entries in
`checkLinks.mjs`'s `PLANNED_ROUTES`; building them emptied the list, and its reverse check is what
forced the deletion. The list itself stays, because §10's documentation routes land in phases 7 and 8
@@ -1111,6 +1140,7 @@ Organised by what a reader is trying to do. A reader should never need to know t
| `/beta/` | The closed-beta signup (§8). The one page that handles its own POST (D28) |
| `/community/` | Discord (`discord.gg/t2Jav8yT4g`) as the front door, the Gitea org for code and contributions, the `brand.json` contact address for vulnerabilities (D13) — the split in §14 N3 |
| `/privacy/`, `/terms/` | §9 |
| `/delete-account/` | §9: how to delete an account you use in the app, and what it keeps. The address Play is given (D85, D86) |
**Feature grouping**, using project terminology:

View File

@@ -1,7 +1,7 @@
<!--
GENERATED FILE — do not edit.
Source: src/data/collection.mjs (scope "app") + src/data/legal.mjs
Source: src/data/collection.mjs (scope "app", and the deletion row) + src/data/legal.mjs
Generator: scripts/playDataSafety.mjs
Edit the data file and run `npm run play:datasafety`. CI runs the same
@@ -76,11 +76,11 @@ Not collected, because none of it leaves the phone; listed so nobody declares it
Purposes: App functionality; Account management; Fraud prevention, security, and compliance.
Signing in sends your username and password to the deployment, or with single sign-on a one-time code from the provider, plus a two-factor code when the account has one. The deployment answers with an access and a refresh token and the username, role and account id they belong to. The tokens are held in encrypted storage on the device (AES-256-GCM through Jetpack Security) and are sent to exactly one place: the deployment that issued them, with every request. Changing your username or password, setting up two-factor sign-in and generating recovery codes from the app go to the same place.
Signing in sends your username and password to the deployment, or with single sign-on a one-time code from the provider, plus a two-factor code when the account has one. The deployment answers with an access and a refresh token and the username, role and account id they belong to. The tokens are held in encrypted storage on the device (AES-256-GCM through Jetpack Security) and are sent to exactly one place: the deployment that issued them, with every request. Changing your username or password, setting up two-factor sign-in and generating recovery codes from the app go to the same place. So does deleting the account, which sends your username, your password and, if the account has one, a two-factor code.
- **Why that answer:** The username, password and tokens leave the phone for the deployment the user chose, and Play counts that as collection whoever runs the server. None of it reaches us. Security is a purpose because the deployment checks two-factor codes and trusted devices with it.
- **Retention:** On the device until you sign out
- **In detail:** Signing out clears them; uninstalling the app removes them with it.
- **Retention:** On the device until you sign out or delete the account
- **In detail:** Signing out clears them, and so does deleting the account; uninstalling the app removes them with it.
- **Read from:** `data/api/AuthApi.kt, data/api/SsoApi.kt, data/api/MeApi.kt, core/auth/EncryptedTokenStore.kt`
### The trusted-device token and the device’s name, if you asked for one
@@ -93,7 +93,7 @@ Ticking “trust this device” during two-factor sign-in stores an opaque token
- **Why that answer:** The token identifies this device to the deployment, and the label is sent with it. Both are presented to the deployment that minted the token and to nothing else.
- **Retention:** On the device until it expires or you revoke it
- **In detail:** Thirty days, and revocable at any time from the deployment’s Trusted Devices screen, which is also where it can be revoked if the phone is lost.
- **In detail:** Thirty days, and revocable at any time from the deployment’s Trusted Devices screen, which is also where it can be revoked if the phone is lost. Deleting the account from the app drops it from the phone too, since there is no next sign-in for it to skip.
- **Read from:** `core/auth/EncryptedTrustTokenStore.kt, core/auth/DeviceNameProvider.kt`
### The app’s version and your Android version
@@ -128,8 +128,8 @@ Purposes: App functionality.
Push is off until you enable it. When you do, the app mints a random, unguessable topic name on the notification relay the deployment nominates, and registers that topic’s URL with the deployment so it has somewhere to send a nudge. What actually travels through the relay is content-free — a stream name and a reference, never the message — and the app then fetches the real content over its authenticated connection to the deployment. A leaked topic name therefore reveals nothing, which is the reason the relay needs no account and holds nothing about you.
- **Why that answer:** The topic URL is an address for this device, and the app sends it to the deployment. The relay is the one the deployment nominates to deliver its own notifications, so it carries them on the deployment’s behalf rather than receiving a share. It only ever sees a topic name and a tickle; the app pulls the content itself, authenticated. No hop reaches a server we operate.
- **Retention:** Until you turn push off, sign out, or uninstall
- **In detail:** Signing out or disabling push unregisters the device with the deployment and discards the topic. The relay retains whatever its own operator configures it to; if the deployment points at a relay it does not run, that relay is a third party to both of us, and it still only ever sees a tickle.
- **Retention:** Until you turn push off, sign out, delete the account, or uninstall
- **In detail:** Signing out or disabling push unregisters the device with the deployment and discards the topic; deleting the account drops the registration on the deployment with everything else. The relay retains whatever its own operator configures it to; if the deployment points at a relay it does not run, that relay is a third party to both of us, and it still only ever sees a tickle.
- **Read from:** `core/push/NtfyTopic.kt, core/push/PushPreferences.kt, data/api/NotificationsApi.kt`
### Linking a game account
@@ -201,8 +201,8 @@ Searching the wiki, a game’s market or its creature atlas sends what you typed
The app keeps the most recent notifications it has already fetched — at most thirty, and only the first page — on the device, so opening the inbox shows you what you had rather than a spinner. It is a copy of what the deployment already sent you and it is refreshed from there; nothing is written here that was not read from your own account. It is scoped to the account that fetched it, so a second person signing in on the same phone is never shown the first one’s messages.
- **Why that answer:** The snapshot is written on the phone from data the deployment had already delivered. It is not uploaded anywhere.
- **Retention:** Until you sign out, or the thirty are pushed out by newer ones
- **In detail:** Signing out deletes the snapshot outright. It lives in the app’s ordinary preference store rather than the encrypted one — sign-in tokens are the thing that store is for — which is worth stating plainly: on a device where someone has root, these are readable, and they are notification bodies rather than credentials.
- **Retention:** Until you sign out or delete the account, or the thirty are pushed out by newer ones
- **In detail:** Signing out, or deleting the account, deletes the snapshot outright. It lives in the app’s ordinary preference store rather than the encrypted one — sign-in tokens are the thing that store is for — which is worth stating plainly: on a device where someone has root, these are readable, and they are notification bodies rather than credentials.
- **Read from:** `core/inbox/DataStoreInboxCache.kt, data/repository/AuthRepository.kt`
### No analytics, no crash reporting, no advertising
@@ -219,9 +219,31 @@ There is no third-party SDK in the app at all — no Firebase, no Crashlytics, n
- **Privacy policy URL:** `/privacy` on this site. It is the URL Play is given, and section 2 of it is about the app specifically.
- **Target audience:** adults. The beta is stated as **18 or older** (D31); the app contains no content directed at children and no age verification.
- **Account deletion:** the app creates no account with us — an account belongs to the deployment the user chose, and is deleted there. The only list we hold is the beta signup, which is erased on request; `/privacy` section 4 says how to ask.
- **Accounts the app can start:** the sign-in screen links to the deployment’s own sign-up page, which opens in the browser, and where a game server offers it the app creates a game account on it. The platform has no self-service account deletion today; a deployment’s admin deletes accounts from its admin panel. Read from: `ui/auth/LoginViewModel.kt`, `data/api/PlayerShardApi.kt`.
- **Data deletion request URL:** the contact address published on `/privacy`, which is read from the mounted `brand.json` rather than typed anywhere in the source (D13).
- **Accounts the app can start:** the sign-in screen links to the deployment’s own sign-up page, which opens in the browser, and where a game server offers it the app creates a game account on it. Read from: `ui/auth/LoginViewModel.kt`, `data/api/PlayerShardApi.kt`.
- **Can users delete their account? Yes,** in the app (*My account* → *Delete my account…*) and on the deployment’s website (*Account* → *Delete my account*). It takes the password, and a two-factor code where the account has one, and the account is deleted at once. The account belongs to the deployment the user chose, so it is deleted there; we hold none. Read from: `ui/auth/DeleteAccountScreen.kt`, `data/api/MeApi.kt`.
- **Delete account URL:** `/delete-account/` on this site (D85). It names the app and us, gives the steps in the app and on the website, says what is deleted and what is kept and for how long, and tells someone who cannot sign in to ask their community’s operator, who can delete any account from the admin panel (D86).
- **Deleting some data without deleting the account:** the deployment holds it, so that is a request to its operator. The one list we hold, the beta signup, is erased on request; `/privacy` section 4 says how to ask.
## What an account deletion deletes and keeps
The console asks what is deleted and what is kept. These are the same words `/delete-account/` renders, from the same row of `src/data/collection.mjs`.
**Deleted at once:**
- your sign-in, password, email address, two-factor settings and recovery codes
- every signed-in device, trusted device and push registration
- your linked sign-ins, such as Google, Discord or Steam
- your game-account links, and anything this site granted in the game through them
- your notification settings and inbox
**Kept:**
- What you wrote where others read it, such as forum posts, stays. Other members see it as written by “[deleted player]”. *Until a moderator or the operator removes it, as with any post.*
- Reports you filed and appeals you made stay as moderation records. Staff can still see who it was, and the activity log keeps its IP addresses. *Until the operator removes them.*
- Event results stay with the score and the in-game name, with no site account behind them. *Until the operator removes them.*
- A game account stays on the game server. Only its link to this site goes. *For as long as the game server keeps it, which is up to whoever runs that server.*
- The record of notifications sent to you keeps a one-way hash of your email address, never the address, and no longer names your account. An address that bounced or was reported as spam stays on the suppression list, so it is never mailed again. *The record is swept after a period the operator sets, 180 days by default. The suppression list does not expire.*
- If you were staff, your username can never be used again, by anyone.
## What the website collects, for the same reviewer
@@ -233,4 +255,4 @@ Not part of the Data Safety form — that form is about the app — but a review
- **Your browser’s user-agent string, truncated** — With the row; blanked on removal.
- **The web server’s access log** — Short-term operational retention, then rotated away.
Last generated from data dated 2026-10-29. Regenerate with `npm run play:datasafety` after any change to what the app stores.
Last generated from data dated 2026-10-30. Regenerate with `npm run play:datasafety` after any change to what the app stores.

View File

@@ -40,6 +40,7 @@ import path from 'node:path';
import {
collectedIn,
deletionRow,
PLAY_DATA_TYPES,
PLAY_PURPOSES,
PLAY_VOCABULARY_READ,
@@ -94,7 +95,9 @@ function render() {
lines.push('<!--');
lines.push(' GENERATED FILE — do not edit.');
lines.push('');
lines.push(` Source: src/data/collection.mjs (scope "app") + src/data/legal.mjs`);
lines.push(
` Source: src/data/collection.mjs (scope "app", and the deletion row) + src/data/legal.mjs`
);
lines.push(` Generator: ${GENERATOR}`);
lines.push('');
lines.push(' Edit the data file and run `npm run play:datasafety`. CI runs the same');
@@ -235,22 +238,50 @@ function render() {
`- **Target audience:** adults. The beta is stated as **${legal.minimumAge} or older** ` +
'(D31); the app contains no content directed at children and no age verification.'
);
lines.push(
'- **Account deletion:** the app creates no account with us — an account belongs to ' +
'the deployment the user chose, and is deleted there. The only list we hold is the ' +
'beta signup, which is erased on request; `/privacy` section 4 says how to ask.'
);
lines.push(
'- **Accounts the app can start:** the sign-in screen links to the deployment’s own ' +
'sign-up page, which opens in the browser, and where a game server offers it the app ' +
'creates a game account on it. The platform has no self-service account ' +
'deletion today; a deployment’s admin deletes accounts from its admin panel. Read ' +
'from: `ui/auth/LoginViewModel.kt`, `data/api/PlayerShardApi.kt`.'
'creates a game account on it. Read from: `ui/auth/LoginViewModel.kt`, ' +
'`data/api/PlayerShardApi.kt`.'
);
lines.push(
'- **Data deletion request URL:** the contact address published on `/privacy`, which ' +
'is read from the mounted `brand.json` rather than typed anywhere in the source (D13).'
'- **Can users delete their account? Yes,** in the app (*My account* → *Delete my ' +
'account…*) and on the deployment’s website (*Account* → *Delete my account*). It ' +
'takes the password, and a two-factor code where the account has one, and the account ' +
'is deleted at once. The account belongs to the deployment the user chose, so it is ' +
'deleted there; we hold none. Read from: `ui/auth/DeleteAccountScreen.kt`, ' +
'`data/api/MeApi.kt`.'
);
lines.push(
'- **Delete account URL:** `/delete-account/` on this site (D85). It names the app and ' +
'us, gives the steps in the app and on the website, says what is deleted and what is ' +
'kept and for how long, and tells someone who cannot sign in to ask their community’s ' +
'operator, who can delete any account from the admin panel (D86).'
);
lines.push(
'- **Deleting some data without deleting the account:** the deployment holds it, so ' +
'that is a request to its operator. The one list we hold, the beta signup, is erased ' +
'on request; `/privacy` section 4 says how to ask.'
);
lines.push('');
/* --------------------------------------------------------------------------------- */
const deletion = deletionRow().deletion;
lines.push('## What an account deletion deletes and keeps');
lines.push('');
lines.push(
'The console asks what is deleted and what is kept. These are the same words ' +
'`/delete-account/` renders, from the same row of `src/data/collection.mjs`.'
);
lines.push('');
lines.push('**Deleted at once:**');
lines.push('');
for (const item of deletion.goes) lines.push(`- ${cell(item)}`);
lines.push('');
lines.push('**Kept:**');
lines.push('');
for (const entry of deletion.stays) lines.push(`- ${cell(entry.what)} *${cell(entry.keep)}*`);
lines.push(`- ${cell(deletion.staff)}`);
lines.push('');
lines.push('## What the website collects, for the same reviewer');

View File

@@ -13,6 +13,8 @@ import platform from '../data/platform.json';
* in the legal bar at the foot rather than in the columns: a legal link is not a thing a
* reader browses to alongside Features, it is a thing they go looking for, and the line
* that already carries the licence and the copyright is where people look.
* `/delete-account/` joined them for the same reason (D85): it is the address Google Play
* is given, and a player looking for it looks where the privacy link is.
*/
// `/beta` renders per request, so the footer it gets must read the mount rather than the
// value baked at build time. See `renderBrand` in src/lib/brand.mjs (phase 11).
@@ -59,6 +61,7 @@ const isExternal = (href: string) => href.startsWith('http');
<span class="site-footer__links">
<a href="/privacy/">Privacy</a>
<a href="/terms/">Terms</a>
<a href="/delete-account/">Delete account</a>
</span>
</p>
<p class="site-footer__meta">

View File

@@ -75,6 +75,16 @@
* @property {Retention} retention
* @property {string} source The file this was read out of, repo-relative.
* @property {PlayMapping} [play]
* @property {Deletion} [deletion] The one row `/delete-account/` renders. See that row.
*
* @typedef {object} Kept
* @property {string} what What stays, and who sees what of it.
* @property {string} keep For how long. Play asks for the retention of what stays.
*
* @typedef {object} Deletion
* @property {string[]} goes What goes with the account, in the deployment's own words.
* @property {Kept[]} stays
* @property {string} staff Said of a staff account only (AD9).
*/
/**
@@ -253,10 +263,13 @@ export const collected = [
'storage on the device (AES-256-GCM through Jetpack Security) and are sent to ' +
'exactly one place: the deployment that issued them, with every request. Changing ' +
'your username or password, setting up two-factor sign-in and generating recovery ' +
'codes from the app go to the same place.',
'codes from the app go to the same place. So does deleting the account, which sends ' +
'your username, your password and, if the account has one, a two-factor code.',
retention: {
summary: 'On the device until you sign out',
detail: 'Signing out clears them; uninstalling the app removes them with it.',
summary: 'On the device until you sign out or delete the account',
detail:
'Signing out clears them, and so does deleting the account; uninstalling the app ' +
'removes them with it.',
},
source:
'data/api/AuthApi.kt, data/api/SsoApi.kt, data/api/MeApi.kt, core/auth/EncryptedTokenStore.kt',
@@ -295,7 +308,9 @@ export const collected = [
summary: 'On the device until it expires or you revoke it',
detail:
'Thirty days, and revocable at any time from the deployment’s Trusted Devices ' +
'screen, which is also where it can be revoked if the phone is lost.',
'screen, which is also where it can be revoked if the phone is lost. Deleting the ' +
'account from the app drops it from the phone too, since there is no next sign-in ' +
'for it to skip.',
},
source: 'core/auth/EncryptedTrustTokenStore.kt, core/auth/DeviceNameProvider.kt',
play: {
@@ -372,10 +387,11 @@ export const collected = [
'authenticated connection to the deployment. A leaked topic name therefore reveals ' +
'nothing, which is the reason the relay needs no account and holds nothing about you.',
retention: {
summary: 'Until you turn push off, sign out, or uninstall',
summary: 'Until you turn push off, sign out, delete the account, or uninstall',
detail:
'Signing out or disabling push unregisters the device with the deployment and ' +
'discards the topic. The relay retains whatever its own operator configures it to; ' +
'discards the topic; deleting the account drops the registration on the deployment ' +
'with everything else. The relay retains whatever its own operator configures it to; ' +
'if the deployment points at a relay it does not run, that relay is a third party ' +
'to both of us, and it still only ever sees a tickle.',
},
@@ -545,9 +561,9 @@ export const collected = [
'second person signing in on the same phone is never shown the first one’s ' +
'messages.',
retention: {
summary: 'Until you sign out, or the thirty are pushed out by newer ones',
summary: 'Until you sign out or delete the account, or the thirty are pushed out by newer ones',
detail:
'Signing out deletes the snapshot outright. It lives in the app’s ordinary ' +
'Signing out, or deleting the account, deletes the snapshot outright. It lives in the app’s ordinary ' +
'preference store rather than the encrypted one — sign-in tokens are the thing ' +
'that store is for — which is worth stating plainly: on a device where someone ' +
'has root, these are readable, and they are notification bodies rather than ' +
@@ -607,6 +623,84 @@ export const collected = [
retention: { summary: 'Set by the operator; nothing expires on its own' },
source: 'website server/db/schema.sql — users, user_identities',
},
/*
* The facts `/delete-account/` is built from (ACCOUNT_DELETION.md §4.5), which is the page
* Google Play is given. A row here, rather than a list on that page, so `/privacy` and the
* deletion page cannot disagree about what a deletion takes.
*
* `goes` and the first four `stays` are the website's own words, from
* `client/src/lib/accountDeletion.js` (WHAT_GOES, WHAT_STAYS), which the app's strings
* repeat: a member who reads this page and then the confirm step must recognise the list.
* Play also asks how long what stays is kept, which the screens do not say, so each
* `stays` entry carries a `keep`. The last entry, the delivery log, is on BACKEND_DESIGN.md
* §6.6's list but not on the screens; it is here because Play asks about everything kept.
*/
{
id: 'deploy-account-deletion',
scope: 'deployment',
title: 'Deleting an account',
body:
'You can delete your own account, on the site from the Account page, or in the app ' +
'from My account. It takes your password, and a two-factor code if the account has ' +
'one, and the account goes at once: there is no waiting period and nothing to ' +
'cancel. Your sign-in, email address, devices, linked sign-ins, game-account links ' +
'and inbox go with it. What you wrote where others read it stays, and other members ' +
'see it as written by “[deleted player]”; staff still see who it was, for ' +
'moderation. An operator can delete any account from the admin panel in the same way.',
retention: {
summary: 'Deleted at once; what stays, and for how long, is on the account-deletion page',
detail:
'Where the site can send email and the account had an address, one last message ' +
'goes to it, saying the account was deleted.',
},
source:
'website server/src/auth/accountDeletion.js, client/src/lib/accountDeletion.js; ' +
'docs/website/ACCOUNT_DELETION.md',
deletion: {
goes: [
'your sign-in, password, email address, two-factor settings and recovery codes',
'every signed-in device, trusted device and push registration',
'your linked sign-ins, such as Google, Discord or Steam',
'your game-account links, and anything this site granted in the game through them',
'your notification settings and inbox',
],
stays: [
{
what:
'What you wrote where others read it, such as forum posts, stays. Other members ' +
'see it as written by “[deleted player]”.',
keep: 'Until a moderator or the operator removes it, as with any post.',
},
{
what:
'Reports you filed and appeals you made stay as moderation records. Staff can ' +
'still see who it was, and the activity log keeps its IP addresses.',
keep: 'Until the operator removes them.',
},
{
what:
'Event results stay with the score and the in-game name, with no site account ' +
'behind them.',
keep: 'Until the operator removes them.',
},
{
what: 'A game account stays on the game server. Only its link to this site goes.',
keep: 'For as long as the game server keeps it, which is up to whoever runs that server.',
},
{
what:
'The record of notifications sent to you keeps a one-way hash of your email ' +
'address, never the address, and no longer names your account. An address that ' +
'bounced or was reported as spam stays on the suppression list, so it is never ' +
'mailed again.',
keep:
'The record is swept after a period the operator sets, 180 days by default. The ' +
'suppression list does not expire.',
},
],
staff: 'If you were staff, your username can never be used again, by anyone.',
},
},
{
id: 'deploy-sessions',
scope: 'deployment',
@@ -757,6 +851,24 @@ export function collectedIn(scope) {
return collected.filter((entry) => entry.scope === scope);
}
/**
* The row `/delete-account/` renders. It throws rather than returning nothing, because
* that page is the address Google Play is given, and with no row it would render as a
* deletion page that names nothing deleted.
*
* @returns {Collected & { deletion: Deletion }}
*/
export function deletionRow() {
const rows = collected.filter((entry) => entry.deletion);
if (rows.length !== 1) {
throw new Error(
`src/data/collection.mjs: expected exactly one row with \`deletion\`, found ${rows.length}.\n` +
'\n/delete-account/ renders that row (ACCOUNT_DELETION.md §4.5).\n'
);
}
return /** @type {Collected & { deletion: Deletion }} */ (rows[0]);
}
/**
* The `app` rows that carry a Play mapping — the Data Safety generator's input.
*

View File

@@ -29,7 +29,7 @@ export const legal = {
* from git: a build timestamp would move on every rebuild and tell a reader nothing,
* and a commit date would move when a stylesheet changed.
*/
lastUpdated: '2026-10-29',
lastUpdated: '2026-10-30',
/**
* The minimum age to sign up for the beta. The org lead's decision, 2026-08-24 (D31).

View File

@@ -19,7 +19,7 @@
"version the homepage hero and the footer show — a protocol and a bundle belong to a game,",
"so they appear on that game's own pages."
],
"moduleApi": "1.19.0",
"moduleApi": "1.20.0",
"$comment_releases": [
"Every repository whose release the site quotes, flat, so checkFacts.mjs reads them all",
@@ -29,7 +29,7 @@
"installer": "v0.8.1",
"Android-app": "v0.6.0",
"link": "v2.3.0",
"Module-uo": "v1.3.0",
"Module-uo": "v1.4.0",
"Rust-Link": "v0.3.0",
"Rust-Plugins": "v0.3.0",
"Module-Rust": "v0.6.3",
@@ -61,7 +61,7 @@
"protocol": 8,
"module": {
"repo": "Module-uo",
"coreApi": "^1.10.0",
"coreApi": "^1.20.0",
"capabilities": ["shard", "atlas", "market", "governors", "guilds", "houses", "champs", "cliloc"]
},
"sidecarRepo": "link",

View File

@@ -196,6 +196,12 @@ const downloadBase = `${releasePage}/download/${release}`;
</p>
</div>
</div>
<!-- D85: the deletion page Play is given, beside the two ways in. -->
<p class="app-delete">
Done with it? <a href="/delete-account/">How to delete the account you use in the
app</a>, and what deleting it keeps.
</p>
</section>
<NotBuilt scope="app" title="What the app does not do" />
@@ -335,6 +341,12 @@ const downloadBase = `${releasePage}/download/${release}`;
padding-top: 0.4rem;
}
.app-delete {
margin: 1.25rem 0 0;
color: var(--muted);
font-size: 0.95rem;
}
.app-get__foot {
margin: 1rem 0 0;
padding-top: 0.85rem;

View File

@@ -0,0 +1,327 @@
---
import Base from '../layouts/Base.astro';
import PageHeader from '../components/PageHeader.astro';
import { deletionRow } from '../data/collection.mjs';
import { legal } from '../data/legal.mjs';
import { brand } from '../lib/brand.mjs';
/**
* `/delete-account/` — the page Google Play is given for account deletion
* (docs/website/ACCOUNT_DELETION.md §4.5, AD2).
*
* ---------------------------------------------------------------------------------------
* IT EXPLAINS; IT CANNOT PERFORM
* ---------------------------------------------------------------------------------------
* Play needs one deletion address from whoever publishes the app, and that is us. But we
* hold no account: every community runs its own site, and the account lives there. So this
* page says where the button is on the website and in the app, what goes and what stays,
* and who to ask when you cannot sign in, which is that community's operator and never us
* (D86). We have no access to any community's site, so offering to help would promise
* something we cannot do.
*
* What goes and what stays is not written here. It is the `deletion` row in
* `src/data/collection.mjs`, the same row `/privacy` section 3 renders, so the two pages
* cannot disagree about what a deletion takes.
*/
const title = 'Delete your account';
const description =
`How to delete an account you use in the ${brand.siteName} Android app or on a ` +
`${brand.siteName} community site, what is deleted, and what is kept.`;
const row = deletionRow();
const { goes, stays, staff } = row.deletion;
const surfaces = [
{
id: 'on-the-website',
eyebrow: 'On the website',
heading: 'Your community’s site',
steps: [
'Sign in to the site.',
'Open “Account”. Staff open “Account” in the admin panel instead.',
'Scroll to the bottom, to “Delete my account”, and press “Delete my account…”.',
'Type your username, then your password, and a two-factor code if the account has two-factor sign-in on.',
'Press “Delete my account”. You are signed out everywhere, and the site says the account has been deleted.',
],
},
{
id: 'in-the-app',
eyebrow: 'In the app',
heading: `The ${brand.siteName} app`,
steps: [
'Sign in, if you are not already.',
'Open the menu and choose “My account”.',
'Scroll to the bottom and tap “Delete my account…”.',
'Type your username, then your password, and a two-factor code if the account has two-factor sign-in on.',
'Tap “Delete my account”. The app forgets the account and returns to sign-in, saying it has been deleted.',
],
},
];
---
<Base title={title} description={description}>
<PageHeader eyebrow="Account deletion" title="Deleting your account">
<p>
For the {brand.siteName} Android app, and for the community sites it connects to. We
are {brand.siteName}, the project that makes both.
</p>
<p>
You can delete your account yourself, on the website or in the app. It is deleted at
once: there is no waiting period and nothing to cancel.
</p>
</PageHeader>
<section class="page section legal-meta">
<p class="legal-meta__line">
<span class="chip chip--version">Last updated {legal.lastUpdated}</span>
<a class="legal-meta__link" href="/privacy/#deployments">The same list is on the privacy page</a>
</p>
</section>
<section class="page section">
<div class="panel del-who">
<h2>Your account is with your community, not with us</h2>
<p>
The app ships pointed at nothing. On first run it asks for the address of a
{' '}{brand.siteName} site, and that site is run by whoever runs that community. Your
account lives there, under their control. We hold no account for you and have no
copy of one, so the deletion happens on that site, and you are the one who does it.
</p>
</div>
</section>
<section class="page section" id="how">
<h2 class="del-h2">How to delete it</h2>
<p class="prose del-lede">
The same steps on both, and either one deletes the account everywhere: deleting it in
the app also deletes it on the website, and the other way round.
</p>
<div class="del-grid">
{
surfaces.map((surface) => (
<div class="panel del-surface" id={surface.id}>
<p class="eyebrow">{surface.eyebrow}</p>
<h3>{surface.heading}</h3>
<ol class="del-steps">
{surface.steps.map((step) => (
<li>{step}</li>
))}
</ol>
</div>
))
}
</div>
<ul class="del-notes">
<li>
<strong>A password is required.</strong> If you only ever signed in with Google,
Discord or Steam and never set a password, set one in the Password section of the
same page first.
</li>
<li>
<strong>The only admin cannot delete themselves.</strong> Make someone else an admin
first, so the site is never left with nobody to run it.
</li>
</ul>
</section>
<section class="page section" id="what-goes">
<div class="del-cols">
<div class="panel del-list">
<p class="eyebrow">Deleted</p>
<h2>What goes with the account</h2>
<ul>
{goes.map((item) => <li>{item[0].toUpperCase() + item.slice(1)}.</li>)}
</ul>
<p class="del-list__foot">{row.retention.detail}</p>
</div>
<div class="panel del-list">
<p class="eyebrow">Kept</p>
<h2>What stays, and for how long</h2>
<ul class="del-kept">
{
stays.map((entry) => (
<li>
{entry.what}
<span class="del-kept__keep">
<span class="del-kept__label">How long</span>
{entry.keep}
</span>
</li>
))
}
</ul>
<p class="del-list__foot">{staff}</p>
</div>
</div>
</section>
<section class="page section" id="cannot-sign-in">
<div class="panel del-who">
<h2>If you cannot sign in</h2>
<ol class="del-steps">
<li>
Use “Forgot your password?” on the sign-in page, on the website or in the app. It
works if your account has an email address and the site can send email.
</li>
<li>
Otherwise, ask the community that runs the site. Its operator can delete any
account from the admin panel, and the result is the same as deleting it yourself.
</li>
</ol>
<p class="del-who__note">
We cannot do it for you. We hold no account, and we have no access to any
community’s site.
</p>
</div>
</section>
</Base>
<style>
/* The header strip, as /privacy and /terms render it. Astro scopes component styles, so
each legal page carries its own copy (see the note in terms.astro). */
/* Unlike /privacy, a panel follows the strip directly, so it keeps some room below. */
.legal-meta {
padding-top: 0;
padding-bottom: 1.25rem;
}
.legal-meta__line {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
margin: 0;
}
.legal-meta__link {
color: var(--dim);
font-size: 0.9rem;
}
.del-h2 {
margin: 0 0 0.75rem;
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
}
.del-lede {
margin: 0 0 1.75rem;
color: var(--muted);
}
/* The two panels that frame the page: who holds the account, and who to ask. Gold edge,
as /app's prerequisite panel, because each is a fact about the product rather than a
warning. */
.del-who {
padding: clamp(1.25rem, 4vw, 2.25rem);
border-color: var(--gold-deep);
}
.del-who h2 {
margin: 0 0 0.7rem;
font-size: clamp(1.3rem, 3vw, 1.7rem);
}
.del-who p,
.del-who li {
max-width: var(--measure);
color: var(--muted);
}
.del-who p {
margin: 0;
}
/* The one sentence a reader who skims must not miss, so it is brighter than the rest. */
.del-who .del-who__note {
margin-top: 1rem;
color: var(--ink);
}
.del-grid,
.del-cols {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 21rem), 1fr));
}
.del-surface,
.del-list {
padding: clamp(1.1rem, 3vw, 1.75rem);
}
.del-surface h3 {
margin: 0 0 0.8rem;
font-size: 1.15rem;
}
.del-list h2 {
margin: 0 0 0.8rem;
font-size: clamp(1.2rem, 2.6vw, 1.45rem);
}
.del-steps {
margin: 0;
padding-left: 1.3rem;
color: var(--muted);
font-size: 0.97rem;
}
.del-steps li + li {
margin-top: 0.45rem;
}
.del-notes {
margin: 1.25rem 0 0;
padding-left: 1.2rem;
max-width: var(--measure);
color: var(--muted);
font-size: 0.95rem;
}
.del-notes li + li {
margin-top: 0.5rem;
}
.del-list ul {
margin: 0;
padding-left: 1.2rem;
color: var(--muted);
font-size: 0.95rem;
}
.del-list li + li {
margin-top: 0.5rem;
}
.del-kept li + li {
margin-top: 0.9rem;
}
.del-kept__keep {
display: block;
margin-top: 0.3rem;
color: var(--dim);
font-size: 0.88rem;
}
.del-kept__label {
display: block;
color: var(--muted);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.11em;
text-transform: uppercase;
}
.del-list__foot {
margin: 1.1rem 0 0;
padding-top: 0.9rem;
border-top: 1px solid var(--line-soft);
color: var(--dim);
font-size: 0.88rem;
}
</style>

View File

@@ -66,6 +66,8 @@ const sections = [
'the app happens between your phone and their server, and there is no account with ' +
'us, no service of ours in the middle, and no copy of anything on our side — because ' +
'we do not operate one.',
/* The deletion page Play is given (D85). The app is where most people will look. */
link: { href: '/delete-account/', text: 'How to delete the account you use in the app' },
rows: collectedIn('app'),
},
{
@@ -79,6 +81,7 @@ const sections = [
'and their policy — this page is not it. What follows is an inventory of what the ' +
'software collects, so that an operator can see plainly what they are taking on, ' +
'and a player can see what to ask their operator about.',
link: { href: '/delete-account/', text: 'What deleting an account takes, and what it keeps' },
rows: collectedIn('deployment'),
},
];
@@ -131,6 +134,11 @@ const sections = [
<h2>{section.heading}</h2>
<p class="legal-sec__who">{section.controller}</p>
<p class="prose legal-sec__lede">{section.lede}</p>
{section.link && (
<p class="legal-sec__link">
<a href={section.link.href}>{section.link.text}</a>
</p>
)}
</div>
<ul class="legal-rows">
@@ -304,6 +312,11 @@ const sections = [
color: var(--muted);
}
.legal-sec__link {
margin: 0.75rem 0 0;
font-size: 0.95rem;
}
.legal-rows {
display: grid;
gap: 1rem;

View File

@@ -33,6 +33,7 @@ import {
assertScopeNonEmpty,
collected,
collectedIn,
deletionRow,
PLAY_DATA_TYPES,
PLAY_PURPOSES,
playRows,
@@ -139,6 +140,40 @@ describe('the Play Data Safety mapping', () => {
});
});
/*
* `/delete-account/` is the address Google Play is given (ACCOUNT_DELETION.md §4.5), and
* Play asks two things of it that render fine when missing: what is deleted, and how long
* what stays is kept. A `stays` entry with no `keep` is a card with no answer to the
* second question.
*/
describe('the account-deletion row', () => {
it('exists exactly once, in the deployment scope', () => {
const row = deletionRow();
assert.equal(row.scope, 'deployment', 'the deletion row is about a deployment, not about us');
});
it('says what goes and what stays, with how long for each', () => {
const { goes, stays, staff } = deletionRow().deletion;
assert.ok(goes.length, 'the deletion row names nothing deleted');
assert.ok(stays.length, 'the deletion row names nothing kept');
for (const entry of stays) {
assert.ok(entry.what?.trim(), 'a kept entry says nothing about what is kept');
assert.ok(entry.keep?.trim(), `"${entry.what}" does not say how long it is kept`);
}
assert.ok(staff?.trim(), 'the staff note (AD9) is missing');
});
/*
* AD5 in test form: members see "[deleted player]", the wording the website and the app
* render. A page that named a different placeholder would describe a product that does
* not exist.
*/
it('names the placeholder members see', () => {
const text = deletionRow().deletion.stays.map((entry) => entry.what).join(' ');
assert.match(text, /“\[deleted player\]”/);
});
});
describe('the minimum age', () => {
it('is stated in the consent sentence the row records', () => {
assert.match(