From a2faf07104e4e0a9f9b23d220e9169eb909b55ef Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 24 Aug 2026 04:21:47 -0500 Subject: [PATCH] =?UTF-8?q?feat(legal):=20phase=206=20=E2=80=94=20the=20pr?= =?UTF-8?q?ivacy=20policy=20and=20the=20terms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitea/workflows/pr-checks.yml | 10 + PLAN.md | 61 ++++- PLAY_DATA_SAFETY.md | 119 +++++++++ README.md | 8 +- package.json | 6 +- scripts/checkLinks.mjs | 9 +- scripts/playDataSafety.mjs | 231 +++++++++++++++++ src/components/Footer.astro | 27 +- src/data/beta.mjs | 34 ++- src/data/collection.mjs | 459 +++++++++++++++++++++++++++++++++ src/data/legal.mjs | 53 ++++ src/pages/beta.astro | 4 +- src/pages/privacy.astro | 395 ++++++++++++++++++++++++++++ src/pages/terms.astro | 259 +++++++++++++++++++ test/legal.test.mjs | 153 +++++++++++ 15 files changed, 1810 insertions(+), 18 deletions(-) create mode 100644 PLAY_DATA_SAFETY.md create mode 100644 scripts/playDataSafety.mjs create mode 100644 src/data/collection.mjs create mode 100644 src/data/legal.mjs create mode 100644 src/pages/privacy.astro create mode 100644 src/pages/terms.astro create mode 100644 test/legal.test.mjs diff --git a/.gitea/workflows/pr-checks.yml b/.gitea/workflows/pr-checks.yml index 61df235..9d8fefd 100644 --- a/.gitea/workflows/pr-checks.yml +++ b/.gitea/workflows/pr-checks.yml @@ -38,6 +38,16 @@ jobs: # rewrite replaces is distinctive enough to replace blindly. run: npm run check:brand + - name: Play Data Safety declaration + # PLAN.md §9 / D33 — PLAY_DATA_SAFETY.md is generated from the same + # src/data/collection.mjs rows that /privacy section 2 renders, so the published + # policy and the answers given to Google cannot drift apart. This re-runs the + # generator and fails if the committed copy differs. + # + # It runs before the build because it needs neither one: it is the cheapest check + # here and the one whose failure is easiest to act on. + run: npm run check:datasafety + - name: Types run: npm run check diff --git a/PLAN.md b/PLAN.md index faf7bf7..500befb 100644 --- a/PLAN.md +++ b/PLAN.md @@ -236,7 +236,7 @@ Taken by the org lead (Colby Whitlock) on 2026-08-19. Recorded so they are not r **Decisions after D13 are recorded where they were taken**, in the section describing the phase that raised them, rather than appended here — a decision is only re-litigated when its reasoning is -somewhere other than the thing it decided. The count of record is **twenty-five**: +somewhere other than the thing it decided. The count of record is **thirty-three**: | # | Where | What it settled | |---|---|---| @@ -244,6 +244,7 @@ somewhere other than the thing it decided. The count of record is **twenty-five* | D17–D19 | §10, "How phase 3 built the homepage" | The data-path diagram, all five groups on the homepage, the emblem-led hero | | D20–D25 | §10, "How phase 4 built the marketing pages" | `/features/` as the same list with detail, `/architecture/` as reasons not reference, the absences as data, the two absorbed scope items, `needsModule`, the demo deep links | | D26–D29 | §8, "How phase 5 built the app and the beta" | The screenshot slot reserved for phase 9, the demo as the tester target, `/beta` handling its own POST, equal billing for the APK and the beta | +| D30–D33 | §9, "How phase 6 built the legal pages" | One logging hop and no edge provider, eighteen or older, no governing-law clause, the Data Safety notes as a generated document | --- @@ -593,6 +594,64 @@ because it cannot. Both pages are linked from the footer on every page, and `/privacy` is the URL given to Play. +### How phase 6 built the legal pages + +Four decisions taken before either page was written (org lead, 2026-08-24). + +**D30 — One hop in front of the site, and the page says so.** §9 requires `/privacy` to state the +access logs and their retention, which needed a fact rather than a guess. The domain's DNS is on +Cloudflare but the records are **DNS-only**: no edge provider terminates the connection, so the +reverse proxy on the org lead's own host keeps the only access log there is — IP, path, user agent, +timestamp — read when something is broken or being attacked, rotated on the proxy's own schedule. +The page describes it qualitatively rather than quoting a retention number, because the number +belongs to the proxy's configuration and a policy that states one the deployment does not enforce is +worse than one that does not. **If the record is ever proxied, this section is wrong and has to be +rewritten** — an edge provider that terminates TLS is a processor, and D9's "no third-party +requests" would still be true of the browser while ceasing to be the whole story. + +**D31 — Eighteen or older.** Play asks, and the answer decides whether consent alone is a lawful +basis in the EEA. Eighteen was chosen over thirteen (Google's own account minimum, but below the +children's-consent threshold in several EEA states, so a 13–15 year old's consent would need a +parent's — which this form cannot obtain) and over sixteen (sufficient, but no simpler to state). +The number lives in `src/data/legal.mjs` because four surfaces render it: `/terms`, `/privacy`, the +eligibility list on `/beta`, and the consent sentence itself. **Nothing verifies it and no surface +implies otherwise** — the pages say in as many words that ticking the box is the whole of it, which +is both accurate and the only claim the code supports. + +Adding the clause changed `CONSENT_TEXT`, which is stored per row rather than versioned — so rows +written from now on carry the new sentence and older ones keep theirs. `CONSENT_VERSION` gained a +suffix rather than a new date, because the change landed on the day the original wording was +written and two different sentences must not share the label an operator groups a CSV by. + +**D32 — No governing-law clause.** Nothing of value is contracted for on this site: it sells +nothing, the software is free under a licence that carries its own terms, and the beta is a list of +addresses people asked to be on. A jurisdiction clause here would be decoration, and §9's standard +for these pages is that accurate and specific beats boilerplate. It stays available: adding one +later is a clause, not a rewrite. + +**D33 — The Play Data Safety notes are a generated repository document.** §9 says the declaration is +"filled from section 2, and section 2 is written knowing that is what it is for" — so the two are +one array, `src/data/collection.mjs`, rendered by `/privacy` as prose and by +`scripts/playDataSafety.mjs` as the console's own questions into a committed +`PLAY_DATA_SAFETY.md`. `--check` regenerates and fails if the committed copy differs, and CI runs +it, so a hand edit is a red build that names the data file to edit instead. The document is +operator-facing rather than published: it is a form's worth of console vocabulary no visitor is +looking for, and `/privacy` already says the same things in prose. + +Two properties of that file are worth keeping. **It does not pretend to know Play's current +definitions** — there is no API to read them from and the requirements have changed more than once +(the same reason `playPolicy` carries a `verifiedOn` date), so it holds the facts arranged as the +console arranges its questions, with the answer each fact supports and why; a person reads the +console's wording against them. And **a test asserts that every mapped row answers "not collected, +not shared"**, failing with the reason rather than a diff: "we operate no server the app talks to" +is the premise of the whole section, and a telemetry endpoint added later must not be able to +produce a row that quietly contradicts the lede three inches above it. + +**What phase 6 also closed.** `/privacy/` and `/terms/` were the last two entries in +`checkLinks.mjs`'s `PLANNED_ROUTES`; building them emptied the list, and its reverse check is what +forced the deletion. The list itself stays, because §10's documentation routes land in phases 7 and 8 +under the same convention. + --- ## 10. Information architecture diff --git a/PLAY_DATA_SAFETY.md b/PLAY_DATA_SAFETY.md new file mode 100644 index 0000000..ca50a70 --- /dev/null +++ b/PLAY_DATA_SAFETY.md @@ -0,0 +1,119 @@ + + +# Google Play Data Safety — the answers, and what they are based on + +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. + +> **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. + +## The premise every answer rests on + +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. + +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. + +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. + +## Data types + +| Category | Data type | Collected by us | Shared by us | Answer | +|---|---|---|---|---| +| Personal info | User IDs | No | No | Not collected by us. | +| Personal info | User IDs | No | No | Not collected by us. | +| App info and performance | Other app data | No | No | Not collected by us. Stored on the device only. | +| Messages | Other in-app messages | No | No | Not collected by us. Declare the relay hop in the console’s free-text security section if it asks. | +| Messages | Other user-generated content | No | No | Not collected by us. | +| Device or other IDs | Device or other IDs | No | No | Not collected. | + +## Each answer, and why it is the truthful one + +### Your sign-in tokens + +**Personal info → User IDs.** Not collected by us. + +When you sign in to a deployment, the app keeps the access and refresh tokens it was issued, plus the username, role and account id they belong to. They are held in encrypted storage on the device (AES-256-GCM through Jetpack Security) and are sent to exactly one place: the deployment that issued them. + +- **Why that answer:** The credentials are issued by, and returned to, a server the user nominated. Nothing reaches an endpoint under our control, because we run none. +- **Retention:** On the device until you sign out +- **In detail:** Signing out clears them; uninstalling the app removes them with it. +- **Read from:** `core/auth/EncryptedTokenStore.kt` + +### The trusted-device token, if you asked for one + +**Personal info → User IDs.** Not collected by us. + +Ticking “trust this device” during two-factor sign-in stores an opaque token so the deployment can skip the second factor next time. It lives in its own encrypted store, deliberately separate from the session, because it has to outlive a sign-out to be worth anything — and the deployment holds only a hash of it, so the copy on your phone is the only usable one. + +- **Why that answer:** Same as the session tokens: minted by the user’s deployment, stored on the device, presented back to that same deployment. +- **Retention:** On the device until it expires or you revoke it +- **In detail:** Thirty days, and revocable at any time from the deployment’s Trusted Devices screen, which is also where it can be revoked if the phone is lost. +- **Read from:** `core/auth/EncryptedTrustTokenStore.kt` + +### The address of the deployment you chose + +**App info and performance → Other app data.** Not collected by us. Stored on the device only. + +The app ships pointed at nothing and asks for an address on first run. That address is stored in ordinary preferences rather than encrypted storage — it is not a secret, it is the equivalent of a bookmark — and it is what every other screen in the app talks to. + +- **Why that answer:** It never leaves the phone. It is the destination of requests, not the contents of one. +- **Retention:** On the device until you change it or uninstall +- **Read from:** `core/prefs/ServerPreferences.kt` + +### Push registration, if you turn notifications on + +**Messages → Other in-app messages.** Not collected by us. Declare the relay hop in the console’s free-text security section if it asks. + +Push is off until you enable it. When you do, the app mints a random, unguessable topic name on the notification relay the deployment nominates, and registers that topic’s URL with the deployment so it has somewhere to send a nudge. What actually travels through the relay is content-free — a stream name and a reference, never the message — and the app then fetches the real content over its authenticated connection to the deployment. A leaked topic name therefore reveals nothing, which is the reason the relay needs no account and holds nothing about you. + +- **Why that answer:** The notification passes through a relay chosen by the deployment, and it carries no content — the app pulls the content itself, authenticated. Neither hop reaches a server we operate. +- **Retention:** Until you turn push off, sign out, or uninstall +- **In detail:** Signing out or disabling push unregisters the device with the deployment and discards the topic. The relay retains whatever its own operator configures it to; if the deployment points at a relay it does not run, that relay is a third party to both of us, and it still only ever sees a tickle. +- **Read from:** `core/push/NtfyTopic.kt, core/push/PushPreferences.kt` + +### Everything you read and post in the app + +**Messages → Other user-generated content.** Not collected by us. + +Forum posts, Team activity, character and shard information, notification preferences: all of it is a live read or write against the deployment. Nothing is cached for offline use and nothing is duplicated anywhere else — the app with no signal is an app with no content, which is a limitation and also an accurate description of where the data lives. + +- **Why that answer:** Content is written to the community’s own installation. We have no copy, no access and no way to obtain one. +- **Retention:** Held by the deployment, under its operator’s policy +- **Read from:** `PLAN.md §9 section 2` + +### No analytics, no crash reporting, no advertising + +**Device or other IDs → Device or other IDs.** Not collected. + +There is no third-party SDK in the app at all — no Firebase, no Crashlytics, no advertising identifier, no measurement library. That is checkable rather than claimed: it is what the dependency list and the manifest say, and a build that gained one would gain permissions with it. + +- **Why that answer:** No advertising ID, no analytics identifier, and no library that would generate one is linked into the build. +- **Retention:** Nothing to retain +- **Read from:** `app/build.gradle.kts, app/src/main/AndroidManifest.xml` + +## The rest of the listing + +- **Privacy policy URL:** `/privacy` on this site. It is the URL Play is given, and section 2 of it is about the app specifically. +- **Target audience:** adults. The beta is stated as **18 or older** (D31); the app contains no content directed at children and no age verification. +- **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. +- **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). + +## What the website collects, for the same reviewer + +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: + +- **Your email address** — Until the beta ends, or until you ask. +- **The wording you agreed to, and when** — For the life of the row. +- **A one-way hash of your IP address — never the address** — With the row; the rate-limit log is pruned after 48 hours. +- **Your browser’s user-agent string, truncated** — With the row; blanked on removal. +- **The web server’s access log** — Short-term operational retention, then rotated away. + +Last generated from data dated 2026-08-24. Regenerate with `npm run play:datasafety` after any change to what the app stores. diff --git a/README.md b/README.md index 82115f1..e4a803a 100644 --- a/README.md +++ b/README.md @@ -41,8 +41,9 @@ makes that would otherwise decay quietly. ```bash npm run check:tokens # no colour literal outside the token file npm run check:brand # the branding pipeline's two quiet failures +npm run check:datasafety # the Play declaration still matches /privacy npm run check # astro check -npm test # the beta signup's decision path +npm test # the beta signup's decision path, and the policy data npm run build && npm run check:links # every internal link resolves (reads the build) GITEA_TOKEN= npm run check:facts # every version agrees with its authority npm run verify # all of the above, in that order @@ -151,6 +152,9 @@ committed so that CI never needs either. ``` src/ data/platform.json Every externally-sourced fact. No version is written in prose. + data/collection.mjs What is collected, in three scopes. /privacy renders it and the + Play Data Safety notes are generated from it — one inventory. + data/legal.mjs The values /terms, /privacy and the consent sentence must share. styles/tokens.css THE token file — the only place a colour literal may appear. styles/global.css The layout shell, built entirely from tokens. styles/starlight.css Restates our tokens as Starlight's, so the docs cannot drift. @@ -170,6 +174,8 @@ brand-default/ The stock brand, baked into the image and always comple scripts/ The build-time checks, plus applyBrand (boot), brand:assets (manual) and beta.mjs (the tester-list CLI). test/ node --test. The logic the other checks cannot see. +PLAY_DATA_SAFETY.md GENERATED. The answers to Google Play's Data Safety form, from + src/data/collection.mjs. Edit the data, run npm run play:datasafety. ``` Two directories are bind mounts at runtime and are **not** in the repository: `brand/` overrides diff --git a/package.json b/package.json index 9cdf129..27334d4 100644 --- a/package.json +++ b/package.json @@ -18,10 +18,12 @@ "check:tokens": "node scripts/checkTokens.mjs", "check:brand": "node scripts/checkBrand.mjs", "check:links": "node scripts/checkLinks.mjs", + "check:datasafety": "node scripts/playDataSafety.mjs --check", + "play:datasafety": "node scripts/playDataSafety.mjs", "beta": "node scripts/beta.mjs", - "test": "node --test test/beta.test.mjs", + "test": "node --test test/beta.test.mjs test/legal.test.mjs", "brand:assets": "node scripts/buildBrandAssets.mjs", - "verify": "npm run check:tokens && npm run check:brand && npm run check && npm test && npm run build && npm run check:links && npm run check:facts" + "verify": "npm run check:tokens && npm run check:brand && npm run check:datasafety && npm run check && npm test && npm run build && npm run check:links && npm run check:facts" }, "dependencies": { "@astrojs/node": "^11.1.4", diff --git a/scripts/checkLinks.mjs b/scripts/checkLinks.mjs index bbe289e..52b3baa 100644 --- a/scripts/checkLinks.mjs +++ b/scripts/checkLinks.mjs @@ -130,8 +130,13 @@ async function findOnDemandRoutes() { * Adding to it is a deliberate act. If a route is not in §10, it does not belong here. */ const PLANNED_ROUTES = new Map([ - ['/privacy/', 'phase 6 — the privacy policy'], - ['/terms/', 'phase 6 — the terms'], + // Empty as of phase 6, which built `/privacy/` and `/terms/` — the last two routes §10 + // named that no page served. The Map stays because §10 is not finished: phases 7 and 8 + // add the documentation journey, and the convention above (link the final route, not the + // route that exists today) is what the list exists to make safe. + // + // An empty list is not a dormant one. Rule 3 below still runs, so adding an entry for a + // route that has since been built fails immediately rather than sitting here unread. ]); /** Planned routes actually linked from somewhere, so the reverse check can be reported. */ diff --git a/scripts/playDataSafety.mjs b/scripts/playDataSafety.mjs new file mode 100644 index 0000000..91abd95 --- /dev/null +++ b/scripts/playDataSafety.mjs @@ -0,0 +1,231 @@ +#!/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); diff --git a/src/components/Footer.astro b/src/components/Footer.astro index 67f1553..f652531 100644 --- a/src/components/Footer.astro +++ b/src/components/Footer.astro @@ -1,5 +1,6 @@ --- import { brand } from '../lib/brand.mjs'; +import { legal } from '../data/legal.mjs'; import platform from '../data/platform.json'; /** @@ -7,9 +8,10 @@ import platform from '../data/platform.json'; * memory. The version chip reads `platform.json` (§12); the contact address and the links * read `brand.json` (§7, D13). * - * `/privacy` and `/terms` are linked from every page (§9) — those pages land in phase 6, - * which is why they are the only two entries deliberately left out of the columns below - * until then. + * `/privacy` and `/terms` are linked from every page (§9). Phase 6 built them and put them + * in the legal bar at the foot rather than in the columns: a legal link is not a thing a + * reader browses to alongside Features, it is a thing they go looking for, and the line + * that already carries the licence and the copyright is where people look. */ const year = new Date().getFullYear(); @@ -71,9 +73,12 @@ const isExternal = (href: string) => href.startsWith('http');