Some checks failed
PR checks / checks (pull_request) Failing after 3m21s
The app now sends reports, blocks and hides (USER_SAFETY.md step 4, Android-app#64). A new app row, app-safety, declares them: App activity -> Other user-generated content (the report's note) and Other actions (the report, block and hide themselves), collected, not shared, required. Other actions moves from "not collected" to collected in PLAY_DATA_SAFETY.md. The deployment row for posted content now says a report keeps a copy of what it reported, and that blocks and hides are kept on the account and deleted with it (user_blocks and user_hidden_items cascade). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
959 lines
46 KiB
JavaScript
959 lines
46 KiB
JavaScript
/**
|
||
* collection.mjs — what is collected, by whom, for how long. PLAN.md §9, built in phase 6.
|
||
*
|
||
* ---------------------------------------------------------------------------------------
|
||
* WHY THE POLICY IS DATA AND NOT PROSE
|
||
* ---------------------------------------------------------------------------------------
|
||
* §9 says the Play Data Safety declaration is "filled from section 2, and section 2 is
|
||
* written knowing that is what it is for". Two documents saying the same thing about the
|
||
* same code is the drift this repository already has two mechanisms against — the
|
||
* capability list (D18) and the absences (D22) — and this is the worst instance of the
|
||
* three, because the two readers are a published legal page and a form at Google that
|
||
* cannot be corrected without a review round.
|
||
*
|
||
* So the inventory is one array. `/privacy` renders it as prose with the reasoning around
|
||
* it; `scripts/playDataSafety.mjs` renders the `app`-scoped rows as the console's own
|
||
* questions. Changing what the app stores means changing one row, and both move (D33).
|
||
*
|
||
* ---------------------------------------------------------------------------------------
|
||
* THE THREE SCOPES, WHICH ARE THE WHOLE POINT OF THE PAGE
|
||
* ---------------------------------------------------------------------------------------
|
||
* §9 is explicit that conflating them "would be wrong in both directions", and the
|
||
* direction people miss is the second one:
|
||
*
|
||
* `site` This website. We are the data controller. It is one form.
|
||
* `app` The Android app. We operate NO server it talks to — every byte goes to
|
||
* a deployment the user typed the address of, run by whoever runs that
|
||
* community. What is listed here is what the device holds and what it
|
||
* sends to that deployment. None of it reaches us, because we receive
|
||
* nothing; Play still counts what is sent as collected (see `PlayMapping`).
|
||
* `deployment` A self-hosted install of the platform. The operator is the controller,
|
||
* not us. This scope exists so an operator sees the responsibility they
|
||
* are taking on, and so a player never mistakes this policy for the one
|
||
* governing their own community's site.
|
||
*
|
||
* ---------------------------------------------------------------------------------------
|
||
* EVERY ROW IS A FACT ABOUT CODE THAT EXISTS
|
||
* ---------------------------------------------------------------------------------------
|
||
* Each entry names the file it was read out of. Not decoration: a privacy policy is the
|
||
* document most likely to be written from a template and least likely to be re-read
|
||
* against the software, and a row that cannot name its source is a row somebody guessed.
|
||
* `checkFacts.mjs` cannot verify these — there is no version number to compare — so the
|
||
* citation is what a reviewer uses instead.
|
||
*/
|
||
|
||
/**
|
||
* @typedef {object} Retention
|
||
* @property {string} summary Short enough to sit in a table cell.
|
||
* @property {string} [detail] The mechanism, where the summary alone would be a promise.
|
||
*
|
||
* @typedef {object} PlayType
|
||
* @property {string} category The console's grouping, a key of `PLAY_DATA_TYPES`.
|
||
* @property {string} type The console's data type within that grouping.
|
||
*
|
||
* @typedef {object} PlayMapping
|
||
* How this row answers Google Play's Data Safety form. `app` scope only — see
|
||
* `scripts/playDataSafety.mjs`, which is the only reader.
|
||
* @property {PlayType[]} types The console data types this row answers.
|
||
* @property {boolean} collected Does it leave the device? Play counts data the app sends
|
||
* off the phone as collected, whoever runs the server it
|
||
* goes to. Ours receives none of it, because we run none;
|
||
* the deployment the user chose receives it (org lead,
|
||
* 2026-10-29: the conservative answers).
|
||
* @property {boolean} shared Is it passed to a third party? Never, for this app.
|
||
* @property {boolean} [required] Collected rows only. Declared required throughout (same
|
||
* decision), rather than arguing feature by feature.
|
||
* @property {string[]} [purposes] Collected rows only, from `PLAY_PURPOSES`.
|
||
* @property {string} answer The recommended console answer, in one line.
|
||
* @property {string} because Why that answer is the truthful one.
|
||
*
|
||
* @typedef {object} Collected
|
||
* @property {string} id
|
||
* @property {'site'|'app'|'deployment'} scope
|
||
* @property {string} title
|
||
* @property {string} body What it is and why it exists.
|
||
* @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).
|
||
*/
|
||
|
||
/**
|
||
* The Data Safety form's own vocabulary: every category and the data types under it, and
|
||
* the purposes a collected type can be given. Read from Play Console Help ("Provide
|
||
* information for Google Play's Data safety section") on `PLAY_VOCABULARY_READ`.
|
||
*
|
||
* Here so a typo or a misfiled type fails a test instead of reaching the console. The
|
||
* first version of this file filed "Other user-generated content" under Messages; it
|
||
* lives under App activity. The full list is also what lets the generator say, for every
|
||
* type the app does NOT collect, that it does not — the form asks about all of them.
|
||
*/
|
||
export const PLAY_VOCABULARY_READ = '2026-10-29';
|
||
|
||
export const PLAY_DATA_TYPES = {
|
||
Location: ['Approximate location', 'Precise location'],
|
||
'Personal info': [
|
||
'Name',
|
||
'Email address',
|
||
'User IDs',
|
||
'Address',
|
||
'Phone number',
|
||
'Race and ethnicity',
|
||
'Political or religious beliefs',
|
||
'Sexual orientation',
|
||
'Other info',
|
||
],
|
||
'Financial info': ['User payment info', 'Purchase history', 'Credit score', 'Other financial info'],
|
||
'Health and fitness': ['Health info', 'Fitness info'],
|
||
Messages: ['Emails', 'SMS or MMS', 'Other in-app messages'],
|
||
'Photos and videos': ['Photos', 'Videos'],
|
||
'Audio files': ['Voice or sound recordings', 'Music files', 'Other audio files'],
|
||
'Files and docs': ['Files and docs'],
|
||
Calendar: ['Calendar events'],
|
||
Contacts: ['Contacts'],
|
||
'App activity': [
|
||
'App interactions',
|
||
'In-app search history',
|
||
'Installed apps',
|
||
'Other user-generated content',
|
||
'Other actions',
|
||
],
|
||
'Web browsing': ['Web browsing history'],
|
||
'App info and performance': ['Crash logs', 'Diagnostics', 'Other app performance data'],
|
||
'Device or other IDs': ['Device or other IDs'],
|
||
};
|
||
|
||
export const PLAY_PURPOSES = [
|
||
'App functionality',
|
||
'Analytics',
|
||
'Developer communications',
|
||
'Advertising or marketing',
|
||
'Fraud prevention, security, and compliance',
|
||
'Personalization',
|
||
'Account management',
|
||
];
|
||
|
||
/** @type {Collected[]} */
|
||
export const collected = [
|
||
/* =====================================================================================
|
||
1. THIS WEBSITE
|
||
|
||
One form, and nothing else. There is no session, no cookie and no script — D9 is not
|
||
a policy statement here, it is a description of the build output: the site sets no
|
||
cookie of any kind, and `Base.astro` loads no third-party origin, so there is nothing
|
||
to disclose beyond the row below and the access log the proxy keeps.
|
||
===================================================================================== */
|
||
{
|
||
id: 'beta-email',
|
||
scope: 'site',
|
||
title: 'Your email address',
|
||
body:
|
||
'The one thing this site asks anybody for. It is stored so it can be pasted into ' +
|
||
'the Google Play tester list, which is the only way Play accepts testers for a ' +
|
||
'closed test. It is not mailed to — this site cannot send email at all — it is not ' +
|
||
'sold, and it is used for nothing else.',
|
||
retention: {
|
||
summary: 'Until the beta ends, or until you ask',
|
||
detail:
|
||
'A removal erases the address itself rather than flagging the row: what is left ' +
|
||
'behind is a date and the fact that a removal happened, which is what lets us ' +
|
||
'answer “did you action my request” without keeping the thing you asked us to ' +
|
||
'let go of.',
|
||
},
|
||
source: 'src/lib/betaStore.mjs',
|
||
},
|
||
{
|
||
id: 'beta-consent',
|
||
scope: 'site',
|
||
title: 'The wording you agreed to, and when',
|
||
body:
|
||
'The exact sentence beside the checkbox is stored with the row, along with the ' +
|
||
'date. A record of consent that cannot reproduce the words somebody actually ' +
|
||
'agreed to is not a record of consent, and the wording can change over time.',
|
||
retention: { summary: 'For the life of the row' },
|
||
source: 'src/data/beta.mjs',
|
||
},
|
||
{
|
||
id: 'beta-ip-hash',
|
||
scope: 'site',
|
||
title: 'A one-way hash of your IP address — never the address',
|
||
body:
|
||
'The form has to survive a script, and the cheapest defence is a limit per source. ' +
|
||
'What is stored is a salted SHA-256 of the address, with the salt held in the ' +
|
||
"server’s environment rather than in the database — so a copy of the file, on its " +
|
||
'own, cannot be turned back into a list of who signed up from where. Rate limiting ' +
|
||
'works perfectly well against a hash. Identifying somebody does not.',
|
||
retention: {
|
||
summary: 'With the row; the rate-limit log is pruned after 48 hours',
|
||
detail:
|
||
'A removal blanks the hash along with the address. Separately, the record of ' +
|
||
'attempts the limiter counts against prunes itself on every write.',
|
||
},
|
||
source: 'src/lib/betaStore.mjs',
|
||
},
|
||
{
|
||
id: 'beta-user-agent',
|
||
scope: 'site',
|
||
title: 'Your browser’s user-agent string, truncated',
|
||
body:
|
||
'The browser identifies itself on every request anyway; this one is kept beside the ' +
|
||
'signup because it is the only signal that separates a person from a script after ' +
|
||
'the fact. It is truncated, because the column is a signal rather than a transcript.',
|
||
retention: { summary: 'With the row; blanked on removal' },
|
||
source: 'src/lib/betaStore.mjs',
|
||
},
|
||
{
|
||
id: 'site-access-log',
|
||
scope: 'site',
|
||
title: 'The web server’s access log',
|
||
body:
|
||
'Ordinary reverse-proxy logging, the same as any web server keeps: the IP address ' +
|
||
'the request came from, the path, the user agent and the time. It is read when ' +
|
||
'something is broken or being attacked, and it is not aggregated, profiled or ' +
|
||
'joined to anything else. Nobody analyses this traffic, because there is nothing ' +
|
||
'here that would benefit from it (D9, D30).',
|
||
retention: {
|
||
summary: 'Short-term operational retention, then rotated away',
|
||
detail:
|
||
'The log belongs to the reverse proxy on the host rather than to this ' +
|
||
'application, so it is the proxy’s rotation that governs it. The site is on ' +
|
||
'DNS-only records: no CDN or edge provider terminates the connection, so this log ' +
|
||
'is the whole of it.',
|
||
},
|
||
source: 'PLAN.md §13 phase 12 — the operator note',
|
||
},
|
||
|
||
/* =====================================================================================
|
||
2. THE ANDROID APP
|
||
|
||
The unusual part, and the part §9 says must be stated precisely: we operate no server
|
||
the app talks to. `ConnectScreen.kt` gates the entire app on an address the user
|
||
enters and the app validates; everything below either stays on the phone or goes to
|
||
that address. There is no telemetry SDK, no crash reporter and no analytics in the
|
||
build — the manifest asks for INTERNET, network state, notifications, a special-use
|
||
foreground service for push and the boot broadcast that restarts it, and nothing else.
|
||
|
||
What goes to that address is still COLLECTED in Play's sense, because it leaves the
|
||
phone, and it is declared that way (org lead, 2026-10-29). Read out of the Retrofit
|
||
interfaces in `data/api/` on the Android app's `edge` (19c2f77): every request body,
|
||
query and header the app sends is in one of the rows below. Rows that stay on the
|
||
phone say so, and carry no console type.
|
||
|
||
`play` is filled in on every row here, because a row in this scope with no mapping is
|
||
a question on the console form that somebody will answer from memory (D33).
|
||
===================================================================================== */
|
||
{
|
||
id: 'app-session-tokens',
|
||
scope: 'app',
|
||
title: 'Your sign-in, and the tokens it issues',
|
||
body:
|
||
'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.',
|
||
retention: {
|
||
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',
|
||
play: {
|
||
types: [{ category: 'Personal info', type: 'User IDs' }],
|
||
collected: true,
|
||
shared: false,
|
||
required: true,
|
||
purposes: [
|
||
'App functionality',
|
||
'Account management',
|
||
'Fraud prevention, security, and compliance',
|
||
],
|
||
answer: 'Collected, not shared, required.',
|
||
because:
|
||
'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.',
|
||
},
|
||
},
|
||
{
|
||
id: 'app-trust-token',
|
||
scope: 'app',
|
||
title: 'The trusted-device token and the device’s name, if you asked for one',
|
||
body:
|
||
'Ticking “trust this device” during two-factor sign-in stores an opaque token so ' +
|
||
'the deployment can skip the second factor next time. It lives in its own encrypted ' +
|
||
'store, deliberately separate from the session, because it has to outlive a sign-out ' +
|
||
'to be worth anything — and the deployment holds only a hash of it, so the copy on ' +
|
||
'your phone is the only usable one. The phone’s make and model, such as “Samsung ' +
|
||
'SM-S918B”, goes with the request as the device’s label, so you can tell it apart ' +
|
||
'in the deployment’s list of trusted devices. It is a model name, not a hardware ' +
|
||
'identifier.',
|
||
retention: {
|
||
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. 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: {
|
||
types: [{ category: 'Device or other IDs', type: 'Device or other IDs' }],
|
||
collected: true,
|
||
shared: false,
|
||
required: true,
|
||
purposes: ['Account management', 'Fraud prevention, security, and compliance'],
|
||
answer: 'Collected, not shared, required.',
|
||
because:
|
||
'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.',
|
||
},
|
||
},
|
||
{
|
||
id: 'app-user-agent',
|
||
scope: 'app',
|
||
title: 'The app’s version and your Android version',
|
||
body:
|
||
'Every request carries a User-Agent such as “RunicGatewayApp/0.6.0 (Android 15)”. ' +
|
||
'It is there because a deployment turns away traffic that looks like a scanner, ' +
|
||
'and it names nothing about you or the phone beyond those two version numbers.',
|
||
retention: {
|
||
summary: 'Under the deployment operator’s policy',
|
||
detail:
|
||
'A deployment’s access log may keep it, as any web server keeps the user agent of ' +
|
||
'a browser.',
|
||
},
|
||
source: 'di/NetworkModule.kt, core/net/UserAgentInterceptor.kt',
|
||
play: {
|
||
types: [{ category: 'App info and performance', type: 'Diagnostics' }],
|
||
collected: true,
|
||
shared: false,
|
||
required: true,
|
||
purposes: ['App functionality', 'Fraud prevention, security, and compliance'],
|
||
answer: 'Collected, not shared, required.',
|
||
because:
|
||
'It is technical information about the app and the device, and it is sent with ' +
|
||
'every request. The deployment uses it to tell the app from a scanner.',
|
||
},
|
||
},
|
||
{
|
||
id: 'app-server-address',
|
||
scope: 'app',
|
||
title: 'The address of the deployment you chose',
|
||
body:
|
||
'The app ships pointed at nothing and asks for an address on first run. That address ' +
|
||
'is stored in ordinary preferences rather than encrypted storage — it is not a ' +
|
||
'secret, it is the equivalent of a bookmark — and it is what every other screen in ' +
|
||
'the app talks to.',
|
||
retention: { summary: 'On the device until you change it or uninstall' },
|
||
source: 'core/prefs/ServerPreferences.kt',
|
||
play: {
|
||
types: [],
|
||
collected: false,
|
||
shared: false,
|
||
answer: 'Not collected. Stored on the device only.',
|
||
because:
|
||
'It never leaves the phone. It is the destination of requests, not the contents ' +
|
||
'of one.',
|
||
},
|
||
},
|
||
{
|
||
id: 'app-push',
|
||
scope: 'app',
|
||
title: 'Push registration, if you turn notifications on',
|
||
body:
|
||
'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.',
|
||
retention: {
|
||
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; 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.',
|
||
},
|
||
source: 'core/push/NtfyTopic.kt, core/push/PushPreferences.kt, data/api/NotificationsApi.kt',
|
||
play: {
|
||
types: [{ category: 'Device or other IDs', type: 'Device or other IDs' }],
|
||
collected: true,
|
||
shared: false,
|
||
required: true,
|
||
purposes: ['App functionality'],
|
||
answer:
|
||
'Collected, not shared, required. Mention the relay in the free-text security ' +
|
||
'section if the console asks.',
|
||
because:
|
||
'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.',
|
||
},
|
||
},
|
||
{
|
||
id: 'app-game-accounts',
|
||
scope: 'app',
|
||
title: 'Linking a game account',
|
||
body:
|
||
'Linking your site account to a game sends the one-time code the game gave you. ' +
|
||
'Where a game server offers it, the app can also create a new game account, which sends ' +
|
||
'the account name and password you chose. Unlinking sends the id being unlinked, ' +
|
||
'such as a Steam id. All of it goes to the deployment, which keeps the link against ' +
|
||
'your site account.',
|
||
retention: {
|
||
summary: 'Held by the deployment until you unlink, under its operator’s policy',
|
||
},
|
||
source: 'data/api/PlayerShardApi.kt, data/api/PlayerRustApi.kt, data/api/MeApi.kt',
|
||
play: {
|
||
types: [{ category: 'Personal info', type: 'User IDs' }],
|
||
collected: true,
|
||
shared: false,
|
||
required: true,
|
||
purposes: ['App functionality', 'Account management'],
|
||
answer: 'Collected, not shared, required.',
|
||
because:
|
||
'Game account names, Steam ids and link codes identify a player, and the app ' +
|
||
'sends them to the deployment.',
|
||
},
|
||
},
|
||
{
|
||
id: 'app-contact',
|
||
scope: 'app',
|
||
title: 'Your name, email address and message, if you use Contact',
|
||
body:
|
||
'The Contact screen sends the name, email address and message you type to the ' +
|
||
'deployment, which passes them on to its operator by email so they can reply. ' +
|
||
'Nothing is sent until you press send.',
|
||
retention: {
|
||
summary: 'Held by the deployment’s operator, under their policy',
|
||
detail: 'The message is delivered to the operator’s mailbox, and kept as long as they keep it.',
|
||
},
|
||
source: 'data/api/PublicApi.kt, data/api/dto/ContactDto.kt',
|
||
play: {
|
||
types: [
|
||
{ category: 'Personal info', type: 'Name' },
|
||
{ category: 'Personal info', type: 'Email address' },
|
||
{ category: 'Messages', type: 'Other in-app messages' },
|
||
],
|
||
collected: true,
|
||
shared: false,
|
||
required: true,
|
||
purposes: ['App functionality'],
|
||
answer: 'Collected, not shared, required.',
|
||
because:
|
||
'The form’s three fields leave the phone for the deployment, whose operator ' +
|
||
'receives them.',
|
||
},
|
||
},
|
||
{
|
||
id: 'app-content',
|
||
scope: 'app',
|
||
title: 'What you post and send from the app',
|
||
body:
|
||
'Most of the app is reading. What it sends is what you write, and most of that is ' +
|
||
'for staff: news posts, wiki categories, a broadcast to the game, the answer to a ' +
|
||
'player’s help page, and the account and reason on a kick or a ban. All of it is a ' +
|
||
'live write to the deployment. Apart from the notification snapshot described ' +
|
||
'below, nothing is cached for offline use and nothing is duplicated anywhere else ' +
|
||
'— the app with no signal is an app with almost no content, which is a limitation ' +
|
||
'and also an accurate description of where the data lives.',
|
||
retention: {
|
||
summary: 'Held by the deployment, under its operator’s policy',
|
||
},
|
||
source: 'data/api/AdminApi.kt',
|
||
play: {
|
||
types: [
|
||
{ category: 'Messages', type: 'Other in-app messages' },
|
||
{ category: 'App activity', type: 'Other user-generated content' },
|
||
],
|
||
collected: true,
|
||
shared: false,
|
||
required: true,
|
||
purposes: ['App functionality'],
|
||
answer: 'Collected, not shared, required.',
|
||
because:
|
||
'Posts, broadcasts and replies are written to the community’s own installation. ' +
|
||
'We have no copy, no access and no way to obtain one.',
|
||
},
|
||
},
|
||
/*
|
||
* Report, block and hide (USER_SAFETY.md §4.3), on Android-app#64 rather than the edge
|
||
* commit the section header names. Read out of `data/api/SafetyApi.kt` and
|
||
* `data/api/dto/SafetyDto.kt`: a report sends the item's type and id, a reason and an
|
||
* optional note; a block and a hide send the item's type and id; unblocking sends the
|
||
* block's id. No request names the person being blocked — the deployment works out who
|
||
* wrote the line — so none of it is a User ID.
|
||
*/
|
||
{
|
||
id: 'app-safety',
|
||
scope: 'app',
|
||
title: 'What you report, block or hide',
|
||
body:
|
||
'Long-pressing a chat line offers Report, Hide for me and Block player. A report ' +
|
||
'sends which line it is, the reason you picked and, if you wrote one, a note of up to ' +
|
||
'500 characters, so the deployment’s staff can review it. Hide for me and Block ' +
|
||
'player send which line it is; the deployment works out who wrote it and keeps the ' +
|
||
'hide or the block on your account, so it applies on the website and on every device. ' +
|
||
'Your list of blocks, and unblocking someone, come from and go to the same place. For ' +
|
||
'staff, Hide for everyone and Show to everyone again send the line too. Nothing is ' +
|
||
'sent until you choose one.',
|
||
retention: {
|
||
summary: 'Held by the deployment, under its operator’s policy',
|
||
detail:
|
||
'A block or a hide lasts until you remove it or delete the account, which deletes ' +
|
||
'them. A report stays as a moderation record, with a copy of what the line said, ' +
|
||
'until the operator removes it.',
|
||
},
|
||
source: 'data/api/SafetyApi.kt, data/api/dto/SafetyDto.kt, data/api/RustApi.kt',
|
||
play: {
|
||
types: [
|
||
{ category: 'App activity', type: 'Other user-generated content' },
|
||
{ category: 'App activity', type: 'Other actions' },
|
||
],
|
||
collected: true,
|
||
shared: false,
|
||
required: true,
|
||
purposes: ['App functionality', 'Fraud prevention, security, and compliance', 'Personalization'],
|
||
answer: 'Collected, not shared, required.',
|
||
because:
|
||
'The report’s note is text the user writes, and reporting, blocking and hiding are ' +
|
||
'actions the app sends to the deployment, which keeps them. Reports exist so staff ' +
|
||
'can act on abuse; blocks and hides change what this one reader is shown.',
|
||
},
|
||
},
|
||
{
|
||
id: 'app-interactions',
|
||
scope: 'app',
|
||
title: 'Which notifications you have read, and which you want',
|
||
body:
|
||
'Opening a notification marks it read on the deployment, so the badge agrees on ' +
|
||
'every device you use. Your choice of notification streams, and of which arrive by ' +
|
||
'push or email, is saved there too.',
|
||
retention: {
|
||
summary: 'Held by the deployment, under its operator’s policy',
|
||
},
|
||
source: 'data/api/NotificationsApi.kt',
|
||
play: {
|
||
types: [{ category: 'App activity', type: 'App interactions' }],
|
||
collected: true,
|
||
shared: false,
|
||
required: true,
|
||
purposes: ['App functionality', 'Personalization'],
|
||
answer: 'Collected, not shared, required.',
|
||
because:
|
||
'Read state and preferences are actions in the app that are sent to, and kept ' +
|
||
'by, the deployment.',
|
||
},
|
||
},
|
||
{
|
||
id: 'app-search',
|
||
scope: 'app',
|
||
title: 'What you search for',
|
||
body:
|
||
'Searching the wiki, a game’s market or its creature atlas sends what you typed ' +
|
||
'to the deployment, because the deployment does the searching. The app keeps no ' +
|
||
'history of searches.',
|
||
retention: {
|
||
summary: 'Under the deployment operator’s policy',
|
||
detail:
|
||
'The query is part of the request’s address, so a deployment’s access log may ' +
|
||
'record it. That is why it is declared rather than called ephemeral.',
|
||
},
|
||
source: 'data/api/PublicApi.kt',
|
||
play: {
|
||
types: [{ category: 'App activity', type: 'In-app search history' }],
|
||
collected: true,
|
||
shared: false,
|
||
required: true,
|
||
purposes: ['App functionality'],
|
||
answer: 'Collected, not shared, required.',
|
||
because: 'Search text leaves the phone in the request to the deployment.',
|
||
},
|
||
},
|
||
{
|
||
id: 'app-inbox-cache',
|
||
scope: 'app',
|
||
title: 'A snapshot of your notifications, so the inbox opens without a signal',
|
||
body:
|
||
'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.',
|
||
retention: {
|
||
summary: 'Until you sign out or delete the account, or the thirty are pushed out by newer ones',
|
||
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.',
|
||
},
|
||
source: 'core/inbox/DataStoreInboxCache.kt, data/repository/AuthRepository.kt',
|
||
play: {
|
||
types: [],
|
||
collected: false,
|
||
shared: false,
|
||
answer: 'Not collected. Stored on the device only.',
|
||
because:
|
||
'The snapshot is written on the phone from data the deployment had already ' +
|
||
'delivered. It is not uploaded anywhere.',
|
||
},
|
||
},
|
||
{
|
||
id: 'app-no-analytics',
|
||
scope: 'app',
|
||
title: 'No analytics, no crash reporting, no advertising',
|
||
body:
|
||
'There is no third-party SDK in the app at all — no Firebase, no Crashlytics, no ' +
|
||
'advertising identifier, no measurement library. That is checkable rather than ' +
|
||
'claimed: it is what the dependency list and the manifest say, and a build that ' +
|
||
'gained one would gain permissions with it.',
|
||
retention: { summary: 'Nothing to retain' },
|
||
source: 'app/build.gradle.kts, app/src/main/AndroidManifest.xml',
|
||
play: {
|
||
types: [{ category: 'App info and performance', type: 'Crash logs' }],
|
||
collected: false,
|
||
shared: false,
|
||
answer: 'Not collected. The advertising ID declaration is "no".',
|
||
because:
|
||
'No crash reporter, no advertising ID, no analytics identifier, and no library ' +
|
||
'that would generate one is linked into the build.',
|
||
},
|
||
},
|
||
|
||
/* =====================================================================================
|
||
3. SELF-HOSTED DEPLOYMENTS
|
||
|
||
Written for an operator deciding what they are taking on, and for a player who found
|
||
this page from their community's site and needs to be told, plainly, that it is not
|
||
the policy governing them.
|
||
===================================================================================== */
|
||
{
|
||
id: 'deploy-accounts',
|
||
scope: 'deployment',
|
||
title: 'Account records',
|
||
body:
|
||
'A username, a password hash, an optional email address, the role, and — where the ' +
|
||
'operator has enabled it — a two-factor secret and single-use recovery codes. The ' +
|
||
'time and IP address of the last sign-in are stored on the account row. A sign-in ' +
|
||
'identity linked to the account — Google, Discord, an OIDC provider or Steam — keeps ' +
|
||
'that provider’s id for the person; a Steam link also keeps the Steam display name ' +
|
||
'and the address of the Steam avatar. Linked identities are deleted with the account.',
|
||
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',
|
||
title: 'Sessions, devices and revocations',
|
||
body:
|
||
'Web sessions, mobile refresh tokens, trusted devices and the revocation list. A ' +
|
||
'trusted-device row keeps a hash of the token, a device label, a truncated user ' +
|
||
'agent and the times it was created and last used.',
|
||
retention: {
|
||
summary: 'Trusted devices expire after 30 days; refresh tokens rotate',
|
||
},
|
||
source: 'website server/db/schema.sql — trusted_devices, mobile_refresh_tokens',
|
||
},
|
||
{
|
||
id: 'deploy-audit',
|
||
scope: 'deployment',
|
||
title: 'An audit log, with IP addresses on it',
|
||
body:
|
||
'Administrative and security-relevant actions are logged with the acting account, ' +
|
||
'what was done, and the IP address it came from. This is the record a moderator ' +
|
||
'relies on, and it is also the most sensitive thing in the database.',
|
||
retention: { summary: 'Kept until the operator removes it' },
|
||
source: 'website server/db/schema.sql — activity_log',
|
||
},
|
||
{
|
||
id: 'deploy-bot-scoring',
|
||
scope: 'deployment',
|
||
title: 'Bot scoring and temporary IP bans',
|
||
body:
|
||
'Scanner traffic and failed logins raise a score against the source address, and a ' +
|
||
'high enough score bans it from the site for an hour. Worth stating precisely ' +
|
||
'because it is better than it sounds: that score lives in memory in the running ' +
|
||
'process, not in the database, and it decays after half an hour of quiet — a ' +
|
||
'restart forgets every address it was watching.',
|
||
retention: {
|
||
summary: 'In memory only; scores decay, bans last an hour',
|
||
},
|
||
source: 'website server/src/middleware/botScore.js',
|
||
},
|
||
{
|
||
id: 'deploy-content',
|
||
scope: 'deployment',
|
||
title: 'Everything posted on the site',
|
||
body:
|
||
'Forum threads and replies, uploads, wiki revisions, moderation actions, warnings, ' +
|
||
'reports and appeals — with the account that made each one. A report keeps a copy ' +
|
||
'of what the reported post or chat line said, so staff read what was reported even ' +
|
||
'after it is edited or pruned. Each member’s blocks and the items they hid for ' +
|
||
'themselves are kept against their account, and deleted with it. Where the operator has ' +
|
||
'connected Discord, some of that crosses into Discord and is then also subject to ' +
|
||
'Discord’s own terms.',
|
||
retention: {
|
||
summary: 'Operator-configured; some sweeps run on a retention window',
|
||
detail:
|
||
'Soft-deleted forum uploads and Team activity have retention windows an admin ' +
|
||
'sets; most other content is kept until somebody removes it.',
|
||
},
|
||
source:
|
||
'website server/db/schema.sql — team_forum_*, mod_actions, content_reports, ' +
|
||
'user_blocks, user_hidden_items',
|
||
},
|
||
{
|
||
id: 'deploy-engagement',
|
||
scope: 'deployment',
|
||
title: 'Notifications, and the record of what was sent',
|
||
body:
|
||
'An operator can have the site notify people about things that happen on it — on ' +
|
||
'the site, by email, by push — so an address is now used for more than getting ' +
|
||
'into an account. Each member chooses this per notification and per channel, and ' +
|
||
'email and push are both off until they ask for them. Alongside that the site ' +
|
||
'keeps a delivery log: what fired, which account, which channel, whether it ' +
|
||
'arrived, and a one-way hash of the address rather than the address. Addresses ' +
|
||
'that bounce or are reported as spam go on a suppression list, which stores the ' +
|
||
'same hash plus a masked form (`d***@example.com`, never the local part) so an ' +
|
||
'operator can see what was suppressed without the list becoming a second address ' +
|
||
'book.',
|
||
retention: {
|
||
summary:
|
||
'The delivery log is kept for a period the operator sets (180 days by default) and then ' +
|
||
'swept; the suppression list does not expire',
|
||
detail:
|
||
'A nightly sweep removes delivery-log entries, finished items from the send queue and the ' +
|
||
'per-person rate-limit rows once they pass the horizon the operator has set for each — ' +
|
||
'the defaults are 180 days for the log and 30 for the other two. Two things deliberately ' +
|
||
'do not expire. An item still waiting to be sent is never swept however old it is, ' +
|
||
'because it is a message the site still intends to deliver. And the suppression list is ' +
|
||
'permanent by design: it records a standing decision to stop mailing an address, and ' +
|
||
'ageing an entry out would mean mailing an address that already bounced or asked to be ' +
|
||
'left alone. An operator can remove an entry from it deliberately, one at a time. ' +
|
||
'Deleting an account detaches its rows from it rather than deleting them — a delivery ' +
|
||
'history stops naming a person, and a suppressed address stays suppressed.',
|
||
},
|
||
source:
|
||
'website server/db/schema.sql — engagement_sends, engagement_suppressions, ' +
|
||
'notification_channel_prefs; server/src/utils/engagementRetentionPrune.js',
|
||
},
|
||
{
|
||
id: 'deploy-events',
|
||
scope: 'deployment',
|
||
title: 'Who took part in a scheduled event',
|
||
body:
|
||
'Where the operator runs scheduled events, a run can count who took part — kept as ' +
|
||
'the name the game module knows a participant by, a score, and a rank, linked to a ' +
|
||
'site account where one is linked and left unlinked where it is not. That is what ' +
|
||
'the published results table renders, and what a signed-in person sees as their own ' +
|
||
'event history. Beside it the site records what each run did: which step ran, what ' +
|
||
'it created or borrowed in the game world, whether the undo succeeded, and which ' +
|
||
'staff account started, paused or cancelled it.',
|
||
retention: {
|
||
summary:
|
||
"A run's diagnostic log is swept after 90 days; the run itself and its participants " +
|
||
'are kept until the operator removes them',
|
||
detail:
|
||
'The log that answers "why did this run stall" is deleted 90 days after a run ' +
|
||
'reaches a terminal state, and only then — a run still in flight keeps every line ' +
|
||
'it has, however old, because the question it answers is still open. The run, its ' +
|
||
'steps, what it created and its participant list are not swept: they are the ' +
|
||
'record of what was done to a shared world, and deleting one silently would ' +
|
||
'unmake an audit. Deleting an account detaches its participation rows rather than ' +
|
||
'removing them — the result table keeps the score and stops naming a person.',
|
||
},
|
||
source:
|
||
'website server/db/schema.sql — event_runs, event_run_participants, ' +
|
||
'event_run_resources, event_run_log; server/src/utils/eventRunner.js',
|
||
},
|
||
{
|
||
id: 'deploy-game-data',
|
||
scope: 'deployment',
|
||
title: 'Game data from the connected server',
|
||
body:
|
||
'Where a game module is installed, what happens on the game server flows to the site ' +
|
||
'through the bridge, and the module keeps it. With Ultima Online that is characters ' +
|
||
'and their accounts, Teams, houses and the in-game economy. With Rust it is each ' +
|
||
'player’s Steam id and in-game name, their statistics, who is online, and the ' +
|
||
'server’s event record — which includes in-game chat, player reports and the IP ' +
|
||
'addresses on sign-in attempts and bans, readable by staff only. A linked Steam ' +
|
||
'account is kept against the site account and deleted with it. Which of any of this ' +
|
||
'the public sees is the operator’s decision, made in the admin panel — the bridge ' +
|
||
'forwards, and the site decides.',
|
||
retention: {
|
||
summary:
|
||
'Operator-configured. The Rust module keeps its event record 30 days, and its ' +
|
||
'sidecar keeps its own copy 14 days by default; statistics until the operator removes them',
|
||
},
|
||
source:
|
||
'docs/link/v7.md — the visibility framework; Module-Rust server/db/schema.sql, ' +
|
||
'server/catalogue.js, server/boot.js (EVENT_RETENTION_DAYS); Rust-Link sidecar/src/config.rs',
|
||
},
|
||
];
|
||
|
||
/** The entries in one scope, in file order. */
|
||
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.
|
||
*
|
||
* A separate accessor rather than a filter at the call site, so the invariant below has
|
||
* somewhere to live: every `app` row MUST map, because the form asks about the app as a
|
||
* whole and a row nobody mapped is a question answered from memory.
|
||
*/
|
||
export function playRows() {
|
||
const rows = collectedIn('app');
|
||
const unmapped = rows.filter((entry) => !entry.play).map((entry) => entry.id);
|
||
|
||
if (unmapped.length) {
|
||
throw new Error(
|
||
`src/data/collection.mjs: app-scoped entries with no Play mapping: ${unmapped.join(', ')}.\n` +
|
||
'\nEvery app row answers a question on the Data Safety form (D33). Add a `play`\n' +
|
||
'block, or move the entry to another scope if it is not about the app.\n'
|
||
);
|
||
}
|
||
|
||
return rows;
|
||
}
|
||
|
||
/**
|
||
* Fails the build when a scope renders nothing.
|
||
*
|
||
* The same guard `notBuilt.mjs` carries, for a stronger reason: an empty section on a
|
||
* privacy policy does not read as an omission, it reads as "we collect nothing here", and
|
||
* that is a claim nobody made.
|
||
*/
|
||
export function assertScopeNonEmpty(scope) {
|
||
if (collectedIn(scope).length) return;
|
||
|
||
throw new Error(
|
||
`src/data/collection.mjs has no entry in scope "${scope}", but /privacy renders it.\n` +
|
||
'\nA section with nothing under it reads as a claim that nothing is collected.\n'
|
||
);
|
||
}
|