Files
runicgateway.com/src/data/collection.mjs
wtclaude dff4232491
Some checks failed
PR checks / checks (pull_request) Failing after 3m21s
fix(legal): declare report, block and hide in the app's data safety
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
2026-10-30 11:02:56 -05:00

959 lines
46 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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'
);
}