/** * 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 therefore mostly what the device * HOLDS, not what we receive, because we receive nothing. * `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} PlayMapping * How this row answers Google Play's Data Safety form. `app` scope only — see * `scripts/playDataSafety.mjs`, which is the only reader. * @property {string} category The console's grouping, e.g. "Personal info". * @property {string} type The console's data type within that grouping. * @property {boolean} collected Does it leave the device to a server WE operate? * @property {boolean} shared Do we pass it to a third party? * @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] * * @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 and a data-sync foreground service, and nothing else. `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 tokens', body: 'When you sign in to a deployment, the app keeps the access and refresh tokens it ' + 'was issued, plus the username, role and account id they belong to. They 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.', retention: { summary: 'On the device until you sign out', detail: 'Signing out clears them; uninstalling the app removes them with it.', }, source: 'core/auth/EncryptedTokenStore.kt', play: { category: 'Personal info', type: 'User IDs', collected: false, shared: false, answer: 'Not collected by us.', because: 'The credentials are issued by, and returned to, a server the user nominated. ' + 'Nothing reaches an endpoint under our control, because we run none.', }, }, { id: 'app-trust-token', scope: 'app', title: 'The trusted-device token, 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.', 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.', }, source: 'core/auth/EncryptedTrustTokenStore.kt', play: { category: 'Personal info', type: 'User IDs', collected: false, shared: false, answer: 'Not collected by us.', because: 'Same as the session tokens: minted by the user’s deployment, stored on the ' + 'device, presented back to that same deployment.', }, }, { 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: { category: 'App info and performance', type: 'Other app data', collected: false, shared: false, answer: 'Not collected by us. 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, 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; ' + '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', play: { category: 'Messages', type: 'Other in-app messages', collected: false, shared: false, answer: 'Not collected by us. Declare the relay hop in the console’s free-text ' + 'security section if it asks.', because: 'The notification passes through a relay chosen by the deployment, and it carries ' + 'no content — the app pulls the content itself, authenticated. Neither hop reaches ' + 'a server we operate.', }, }, { id: 'app-content', scope: 'app', title: 'Everything you read and post in the app', body: 'Forum posts, Team activity, character and shard information, notification ' + 'preferences: all of it is a live read or write against the deployment. Apart from ' + 'the notification snapshot described in the next entry, 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: 'PLAN.md §9 section 2', play: { category: 'Messages', type: 'Other user-generated content', collected: false, shared: false, answer: 'Not collected by us.', because: 'Content is written to the community’s own installation. We have no copy, no ' + 'access and no way to obtain one.', }, }, { 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 the thirty are pushed out by newer ones', 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.', }, source: 'core/inbox/DataStoreInboxCache.kt, data/repository/AuthRepository.kt', play: { category: 'Messages', type: 'Other in-app messages', collected: false, shared: false, answer: 'Not collected by us. 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, and no server we operate is on either ' + 'end of it.', }, }, { 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: { category: 'Device or other IDs', type: 'Device or other IDs', collected: false, shared: false, answer: 'Not collected.', because: '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.', retention: { summary: 'Set by the operator; nothing expires on its own' }, source: 'website server/db/schema.sql — users', }, { 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. 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', }, { 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: 'Kept until the operator removes them; nothing here expires on its own', detail: 'Stated plainly because it is the answer people assume the other way round: ' + 'the delivery log, the suppression list and the per-person rate limits have no ' + 'retention sweep, so they are as long as the site is old. 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', }, { id: 'deploy-game-data', scope: 'deployment', title: 'Game data from the connected server', body: 'Where a game module is installed, information about characters, guilds, houses and ' + 'the in-game economy flows from the game server to the site through the bridge. ' + 'Which of it is visible to the public is the operator’s decision, made in the ' + 'admin panel — the bridge itself forwards, and the site decides.', retention: { summary: 'Operator-configured' }, source: 'docs/link/v4.md — the visibility framework', }, ]; /** The entries in one scope, in file order. */ export function collectedIn(scope) { return collected.filter((entry) => entry.scope === scope); } /** * 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' ); }