#!/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, 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'); 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( 'That is why nearly every answer below is "not collected", and it is also the answer ' + 'most likely to be questioned in a review. The supporting facts are in the table: ' + 'each row names the file it was read out of.' ); lines.push(''); lines.push( 'Where the console offers free text about security practices, two things are worth ' + 'saying: 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(''); /* --------------------------------------------------------------------------------- */ lines.push('## Data types'); lines.push(''); lines.push('| Category | Data type | Collected by us | Shared by us | Answer |'); lines.push('|---|---|---|---|---|'); for (const row of rows) { lines.push( `| ${cell(row.play.category)} | ${cell(row.play.type)} | ${yesNo(row.play.collected)} ` + `| ${yesNo(row.play.shared)} | ${cell(row.play.answer)} |` ); } lines.push(''); lines.push('## Each answer, and why it is the truthful one'); lines.push(''); for (const row of rows) { lines.push(`### ${row.title}`); lines.push(''); lines.push(`**${row.play.category} → ${row.play.type}.** ${cell(row.play.answer)}`); 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( '- **Account deletion:** the app creates no account with us — an account belongs to ' + 'the deployment the user chose, and is deleted there. The only list we hold is the ' + 'beta signup, which is erased on request; `/privacy` section 4 says how to ask.' ); lines.push( '- **Data deletion request URL:** the contact address published on `/privacy`, which ' + 'is read from the mounted `brand.json` rather than typed anywhere in the source (D13).' ); 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);