#!/usr/bin/env node /** * playDataSafety.mjs — PLAN.md §9, phase 6 (D33). * * Writes `PLAY_DATA_SAFETY.md`: the answers to Google Play's Data Safety form, generated * from the same `src/data/collection.mjs` rows that `/privacy` section 2 renders. * * --------------------------------------------------------------------------------------- * WHY IT IS GENERATED AND CHECKED RATHER THAN WRITTEN * --------------------------------------------------------------------------------------- * §9 says the declaration is "filled from section 2, and section 2 is written knowing that * is what it is for". Two documents describing the same code drift — that is the premise of * `capabilities.mjs` (D18) and `notBuilt.mjs` (D22) — and this pair drifts worse than * either, because one half is a published legal page and the other is a form at Google that * cannot be corrected without a review round. The app gaining a crash reporter must not be * able to leave a "not collected" answer standing in a file nobody re-reads. * * So the markdown is an output, not a source. `--check` recomputes it and fails if the * committed copy differs, which is what puts it in `verify` and in CI: editing the doc by * hand fails the build and names the data file to edit instead. * * --------------------------------------------------------------------------------------- * WHAT THIS DOCUMENT IS NOT * --------------------------------------------------------------------------------------- * It is not a filled-in form and it does not claim to know Play's current definitions. * Play's testing and disclosure requirements have changed more than once — §8 says so, and * `playPolicy.verifiedOn` exists for the same reason — and there is no API to read them * from. What this generates is the FACTS, arranged as the console arranges its questions, * with the answer each fact supports and why. Whoever fills the form reads the console's * own definitions against these, which is a job for a person; what they must never do is * answer from memory about what the app stores. * * node scripts/playDataSafety.mjs # write PLAY_DATA_SAFETY.md * node scripts/playDataSafety.mjs --check # fail if the committed copy is stale */ import { readFileSync, writeFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; import { collectedIn, deletionRow, PLAY_DATA_TYPES, PLAY_PURPOSES, PLAY_VOCABULARY_READ, playRows, } from '../src/data/collection.mjs'; import { legal } from '../src/data/legal.mjs'; const ROOT = fileURLToPath(new URL('..', import.meta.url)); const OUT = path.join(ROOT, 'PLAY_DATA_SAFETY.md'); const CHECK = process.argv.includes('--check'); const GENERATOR = 'scripts/playDataSafety.mjs'; /** Cell text: the table is markdown, so a pipe would end the column early. */ const cell = (text) => String(text).replace(/\|/g, '\\|').replace(/\s*\n\s*/g, ' '); const yesNo = (value) => (value ? 'Yes' : 'No'); /** * The form asks per data type, not per row, and two rows can answer the same type (the * trusted-device token and the push topic are both "Device or other IDs"). So the * collected rows are folded into one entry per type, in the console's order: required if * any row requires it, the union of the purposes, and the rows it came from. */ function declaredTypes(rows) { const entries = []; for (const [category, types] of Object.entries(PLAY_DATA_TYPES)) { for (const type of types) { const from = rows.filter( (row) => row.play.collected && row.play.types.some((t) => t.category === category && t.type === type) ); if (!from.length) continue; const purposes = new Set(from.flatMap((row) => row.play.purposes)); entries.push({ category, type, required: from.some((row) => row.play.required), purposes: PLAY_PURPOSES.filter((p) => purposes.has(p)), rows: from, }); } } return entries; } function render() { const rows = playRows(); const site = collectedIn('site'); const lines = []; lines.push(''); lines.push(''); lines.push('# Google Play Data Safety — the answers, and what they are based on'); lines.push(''); lines.push( 'The Play Console asks, for every category of data, whether the app **collects** it, ' + 'whether it is **shared**, whether collection is **required or optional**, and *why*. ' + 'This file holds the answers for the Runic Gateway Android app, generated from the ' + 'same inventory the published privacy policy renders — see `/privacy`, section 2.' ); lines.push(''); lines.push( '> **This is not a filled-in form.** Play’s definitions change and no check here can ' + 'read them. Every answer below is a fact about the code with the reasoning attached; ' + 'read the console’s current wording against them when you fill the form. What this ' + 'file exists to prevent is somebody answering from memory about what the app stores.' ); lines.push(''); /* --------------------------------------------------------------------------------- The one answer that shapes every other one. --------------------------------------------------------------------------------- */ lines.push('## The premise every answer rests on'); lines.push(''); lines.push( 'We operate **no server the app talks to.** The app ships pointed at nothing: its ' + 'first screen asks for the address of a Runic Gateway deployment and validates it ' + 'before anything else in the app runs. That deployment belongs to whoever runs that ' + 'community. Data therefore travels from the device to *their* server, and there is ' + 'no endpoint of ours anywhere in the path — not for content, not for telemetry, and ' + 'not for crash reports, of which there are none.' ); lines.push(''); lines.push( 'Play still counts what the app sends to that server as **collected**: the data leaves ' + 'the device, and the form does not ask whose server it lands on. So everything the ' + 'app sends is declared collected, not shared, and required, which is the conservative ' + 'answer and the accurate one (org lead, 2026-10-29). What stays true, and what ' + '`/privacy` says, is that none of it reaches us.' ); lines.push(''); /* --------------------------------------------------------------------------------- */ lines.push('## Data collection and security'); lines.push(''); lines.push('- **Does the app collect or share any of the required user data types?** Yes, it collects them (below). It shares none.'); lines.push( '- **Is all of the user data collected by the app encrypted in transit?** Yes. The ' + 'release build’s network security config sets `cleartextTrafficPermitted="false"` ' + 'for every connection, and the app refuses an `http` deployment address outside ' + 'debug builds. Read from: `app/src/main/res/xml/network_security_config.xml`, ' + '`core/net/ServerUrl.kt`.' ); lines.push( '- **Is any of it processed ephemerally?** No. The deployment keeps what it receives, ' + 'under its operator’s policy, and we cannot promise otherwise on its behalf.' ); lines.push( '- **Free text on security practices**, where the console offers it: credentials are ' + 'held in Android’s encrypted storage (AES-256-GCM via Jetpack Security), and push ' + 'notifications carry **no content**: a relay receives a stream name and a reference, ' + 'and the app fetches the actual message over its own authenticated connection.' ); lines.push(''); /* --------------------------------------------------------------------------------- */ const declared = declaredTypes(rows); lines.push('## Data types the app collects'); lines.push(''); lines.push('| Category | Data type | Collected | Shared | Required | Purposes | From |'); lines.push('|---|---|---|---|---|---|---|'); for (const entry of declared) { lines.push( `| ${cell(entry.category)} | ${cell(entry.type)} | Yes | No | ${yesNo(entry.required)} ` + `| ${cell(entry.purposes.join('; '))} | ${cell(entry.rows.map((r) => r.title).join('; '))} |` ); } lines.push(''); lines.push('## Data types the app does not collect'); lines.push(''); lines.push( 'The form lists every type, so every one is answered. Vocabulary read from Play ' + `Console Help on ${PLAY_VOCABULARY_READ}; check it against the console’s current list.` ); lines.push(''); for (const [category, types] of Object.entries(PLAY_DATA_TYPES)) { const rest = types.filter((type) => !declared.some((e) => e.category === category && e.type === type)); if (!rest.length) continue; const all = rest.length === types.length; lines.push(`- **${category}:** ${all ? 'none collected' : `${rest.join(', ')}: not collected`}.`); } lines.push(''); const onDevice = rows.filter((row) => !row.play.collected); lines.push('## Held on the device only'); lines.push(''); lines.push('Not collected, because none of it leaves the phone; listed so nobody declares it by mistake.'); lines.push(''); for (const row of onDevice) lines.push(`- **${cell(row.title)}.** ${cell(row.play.because)}`); lines.push(''); lines.push('## Each answer, and why it is the truthful one'); lines.push(''); for (const row of rows) { const types = row.play.types.map((t) => `${t.category} → ${t.type}`).join('; '); lines.push(`### ${row.title}`); lines.push(''); lines.push(`**${types || 'No console type'}.** ${cell(row.play.answer)}`); lines.push(''); if (row.play.collected) { lines.push(`Purposes: ${row.play.purposes.join('; ')}.`); lines.push(''); } lines.push(cell(row.body)); lines.push(''); lines.push(`- **Why that answer:** ${cell(row.play.because)}`); lines.push(`- **Retention:** ${cell(row.retention.summary)}`); if (row.retention.detail) lines.push(`- **In detail:** ${cell(row.retention.detail)}`); lines.push(`- **Read from:** \`${row.source}\``); lines.push(''); } /* --------------------------------------------------------------------------------- */ lines.push('## The rest of the listing'); lines.push(''); lines.push( `- **Privacy policy URL:** \`/privacy\` on this site. It is the URL Play is given, and ` + 'section 2 of it is about the app specifically.' ); lines.push( `- **Target audience:** adults. The beta is stated as **${legal.minimumAge} or older** ` + '(D31); the app contains no content directed at children and no age verification.' ); lines.push( '- **Accounts the app can start:** the sign-in screen links to the deployment’s own ' + 'sign-up page, which opens in the browser, and where a game server offers it the app ' + 'creates a game account on it. Read from: `ui/auth/LoginViewModel.kt`, ' + '`data/api/PlayerShardApi.kt`.' ); lines.push( '- **Can users delete their account? Yes,** in the app (*My account* → *Delete my ' + 'account…*) and on the deployment’s website (*Account* → *Delete my account*). It ' + 'takes the password, and a two-factor code where the account has one, and the account ' + 'is deleted at once. The account belongs to the deployment the user chose, so it is ' + 'deleted there; we hold none. Read from: `ui/auth/DeleteAccountScreen.kt`, ' + '`data/api/MeApi.kt`.' ); lines.push( '- **Delete account URL:** `/delete-account/` on this site (D85). It names the app and ' + 'us, gives the steps in the app and on the website, says what is deleted and what is ' + 'kept and for how long, and tells someone who cannot sign in to ask their community’s ' + 'operator, who can delete any account from the admin panel (D86).' ); lines.push( '- **Deleting some data without deleting the account:** the deployment holds it, so ' + 'that is a request to its operator. The one list we hold, the beta signup, is erased ' + 'on request; `/privacy` section 4 says how to ask.' ); lines.push(''); /* --------------------------------------------------------------------------------- */ const deletion = deletionRow().deletion; lines.push('## What an account deletion deletes and keeps'); lines.push(''); lines.push( 'The console asks what is deleted and what is kept. These are the same words ' + '`/delete-account/` renders, from the same row of `src/data/collection.mjs`.' ); lines.push(''); lines.push('**Deleted at once:**'); lines.push(''); for (const item of deletion.goes) lines.push(`- ${cell(item)}`); lines.push(''); lines.push('**Kept:**'); lines.push(''); for (const entry of deletion.stays) lines.push(`- ${cell(entry.what)} *${cell(entry.keep)}*`); lines.push(`- ${cell(deletion.staff)}`); lines.push(''); lines.push('## What the website collects, for the same reviewer'); lines.push(''); lines.push( 'Not part of the Data Safety form — that form is about the app — but a reviewer who ' + 'follows the privacy policy URL lands on a page covering three things, so it is ' + 'worth knowing which of them the site itself is responsible for:' ); lines.push(''); for (const row of site) { lines.push(`- **${cell(row.title)}** — ${cell(row.retention.summary)}.`); } lines.push(''); lines.push( `Last generated from data dated ${legal.lastUpdated}. Regenerate with ` + '`npm run play:datasafety` after any change to what the app stores.' ); lines.push(''); return lines.join('\n'); } const rendered = render(); if (!CHECK) { writeFileSync(OUT, rendered, 'utf8'); console.log(`playDataSafety: wrote ${path.relative(ROOT, OUT)}`); process.exit(0); } /** * Line endings are normalised before comparing, and that is not fussiness. * * The repository has no `.gitattributes` and Windows checkouts run with * `core.autocrlf=true`, so this file is stored with LF and lands on a Windows disk with * CRLF. A byte comparison would then fail for every developer on Windows while passing in * CI — the worst shape a check can have, because the fix people reach for is to stop * running it. What is being asserted is that the CONTENT agrees, and a line ending is not * content. */ const normalise = (text) => text.split('\r\n').join('\n'); let committed = null; try { committed = readFileSync(OUT, 'utf8'); } catch { /* handled below */ } if (committed !== null && normalise(committed) === normalise(rendered)) { console.log('playDataSafety: PLAY_DATA_SAFETY.md matches src/data/collection.mjs.'); process.exit(0); } console.error( `\nplayDataSafety: ${path.relative(ROOT, OUT)} is ${committed === null ? 'missing' : 'stale'}.\n\n` + ' It is generated from src/data/collection.mjs — the same rows /privacy renders —\n' + ' so that the published policy and the Data Safety declaration cannot disagree\n' + ' (§9, D33). Run:\n\n' + ' npm run play:datasafety\n\n' + ' and commit the result. If the change came from editing the markdown by hand,\n' + ' make it in the data file instead: the page has to move with it.\n' ); process.exit(1);