Some checks failed
PR checks / checks (pull_request) Failing after -31s
Step 5 of docs/website/ACCOUNT_DELETION.md (§4.5). The page explains how to delete an account on a community's site and in the app, what goes, what stays and for how long, and who to ask when you cannot sign in. It cannot delete anything, because we hold no account. - The facts are one deployment row in collection.mjs (deletion.goes, .stays with a keep each, .staff), in the website's own words. /privacy section 3 renders it as prose, the page renders its lists, and PLAY_DATA_SAFETY.md prints them under the console's deletion questions. - The app rows that a deletion touches (session, trust token, push, inbox snapshot) say so. The Data Safety notes now answer "yes" to account deletion and name the URL. lastUpdated is 2026-10-30. - D85: linked from the footer's legal bar, /privacy sections 2 and 3, and /app/'s "Two ways to get it". - D86: someone who cannot sign in is sent to their community's operator, never to us. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
350 lines
16 KiB
JavaScript
350 lines
16 KiB
JavaScript
#!/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(' GENERATED FILE — do not edit.');
|
||
lines.push('');
|
||
lines.push(
|
||
` Source: src/data/collection.mjs (scope "app", and the deletion row) + src/data/legal.mjs`
|
||
);
|
||
lines.push(` Generator: ${GENERATOR}`);
|
||
lines.push('');
|
||
lines.push(' Edit the data file and run `npm run play:datasafety`. CI runs the same');
|
||
lines.push(' generator with --check, so a hand edit here fails the build rather than');
|
||
lines.push(' quietly disagreeing with the published privacy policy.');
|
||
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);
|