Files
runicgateway.com/scripts/playDataSafety.mjs
wtclaude a2faf07104
All checks were successful
PR checks / checks (pull_request) Successful in 55s
feat(legal): phase 6 — the privacy policy and the terms
PLAN.md §9. Builds /privacy and /terms, links them from the footer on every page,
and generates the Play Data Safety notes from the same inventory the policy renders.

Four decisions taken by the org lead before either page was written, recorded in
§9 under "How phase 6 built the legal pages":

  D30  DNS-only records, so the reverse proxy on the host keeps the only access
       log. Described qualitatively — the retention belongs to the proxy, and a
       policy that quotes a number the deployment does not enforce is worse than
       one that does not.
  D31  Eighteen or older. Above the children's-consent threshold everywhere in the
       EEA, so consent works with no parental-consent machinery this form could not
       honestly operate. Four surfaces render it from src/data/legal.mjs, and every
       one says plainly that nothing verifies it.
  D32  No governing-law clause. Nothing of value is contracted for here.
  D33  PLAY_DATA_SAFETY.md is generated from src/data/collection.mjs and checked in
       CI, so the published policy and the answers given to Google cannot drift.

/privacy is three separately-scoped sections because "we" means three different
parties: this site (one form, no cookies, no third-party requests), the Android app
(we operate no server it talks to — the rows are what the DEVICE holds), and a
self-hosted deployment (the operator is the controller, not us). Every row names the
file it was read out of, because a policy is the document most likely to be written
from a template and least likely to be re-read against the software.

/terms governs only what we run: this site, the beta list, and the APK we publish.
The software is governed by its licence, and a community's deployment by that
community — a terms page claiming authority over every install of a GPL program is
the thing a generated template gets wrong.

Also here:
  - the age clause changed CONSENT_TEXT, so CONSENT_VERSION gained a suffix; rows
    written from now on carry the new sentence and older rows keep theirs
  - PLANNED_ROUTES is now empty — these were its last two entries, and its reverse
    check is what forced the deletion; the list stays for phases 7 and 8
  - test/legal.test.mjs asserts the structural promises no build check can see,
    including that every mapped Play row still answers "not collected, not shared"
  - --check normalises line endings: the repo has no .gitattributes and Windows
    checkouts are CRLF, so a byte comparison would fail for every Windows developer
    while passing in CI

Verified: npm run verify green end to end (tokens, brand, data safety, astro check,
36 tests, build, 214 links, 19 facts), both pages walked in a browser, and neither
overflows at 390px. One defect the checks could not see and a look could: the
retention line was being pushed to the foot of the tallest card in its row, opening
a void in the middle of the short ones.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 04:21:47 -05:00

232 lines
10 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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(' GENERATED FILE — do not edit.');
lines.push('');
lines.push(` Source: src/data/collection.mjs (scope "app") + 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.** Plays definitions change and no check here can ' +
'read them. Every answer below is a fact about the code with the reasoning attached; ' +
'read the consoles 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 Androids 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);