fix(legal): declare what the app sends as collected, not shared (D84)
All checks were successful
PR checks / checks (pull_request) Successful in 2m33s
All checks were successful
PR checks / checks (pull_request) Successful in 2m33s
Play counts data that leaves the device as collected, whoever runs the server it lands on. The generated Data Safety notes answered "not collected" because we run no server, which is true of /privacy and the wrong answer to the form (org lead, 2026-10-29, Android PLAN.md M8). An audit of the app's edge (every body, query and header in data/api/) found more leaving the phone than section 2 listed. New rows: the User-Agent, game-account links and creation, the Contact form, staff posts and moderation, notification read state and preferences, search text. The sign-in and trusted-device rows now say what is sent (the credentials, the device's make and model). - PlayMapping: a list of console types, plus required and purposes, checked against PLAY_DATA_TYPES / PLAY_PURPOSES (the first version filed "Other user-generated content" under Messages). - The generator answers per data type, lists every type not collected, and answers the form's security questions. - The premise test now asserts what is still true: nothing is shared. - legal.lastUpdated moves to 2026-10-29 with /privacy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
@@ -38,7 +38,13 @@ 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 {
|
||||
collectedIn,
|
||||
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));
|
||||
@@ -52,6 +58,33 @@ 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');
|
||||
@@ -101,40 +134,87 @@ function render() {
|
||||
);
|
||||
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.'
|
||||
'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 types');
|
||||
lines.push('## Data collection and security');
|
||||
lines.push('');
|
||||
lines.push('| Category | Data type | Collected by us | Shared by us | Answer |');
|
||||
lines.push('|---|---|---|---|---|');
|
||||
for (const row of rows) {
|
||||
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(row.play.category)} | ${cell(row.play.type)} | ${yesNo(row.play.collected)} ` +
|
||||
`| ${yesNo(row.play.shared)} | ${cell(row.play.answer)} |`
|
||||
`| ${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(`**${row.play.category} → ${row.play.type}.** ${cell(row.play.answer)}`);
|
||||
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)}`);
|
||||
@@ -160,6 +240,13 @@ function render() {
|
||||
'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(
|
||||
'- **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. The platform has no self-service account ' +
|
||||
'deletion today; a deployment’s admin deletes accounts from its admin panel. Read ' +
|
||||
'from: `ui/auth/LoginViewModel.kt`, `data/api/PlayerShardApi.kt`.'
|
||||
);
|
||||
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).'
|
||||
|
||||
Reference in New Issue
Block a user