Merge pull request 'feat(legal): phase 6 — the privacy policy and the terms' (#9) from feat/phase-6-legal into main
All checks were successful
PR checks / checks (push) Successful in 9m12s
All checks were successful
PR checks / checks (push) Successful in 9m12s
Reviewed-on: #9
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
61
PLAN.md
61
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
|
||||
|
||||
119
PLAY_DATA_SAFETY.md
Normal file
119
PLAY_DATA_SAFETY.md
Normal file
@@ -0,0 +1,119 @@
|
||||
<!--
|
||||
GENERATED FILE — do not edit.
|
||||
|
||||
Source: src/data/collection.mjs (scope "app") + src/data/legal.mjs
|
||||
Generator: scripts/playDataSafety.mjs
|
||||
|
||||
Edit the data file and run `npm run play:datasafety`. CI runs the same
|
||||
generator with --check, so a hand edit here fails the build rather than
|
||||
quietly disagreeing with the published privacy policy.
|
||||
-->
|
||||
|
||||
# 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.
|
||||
@@ -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=<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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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. */
|
||||
|
||||
231
scripts/playDataSafety.mjs
Normal file
231
scripts/playDataSafety.mjs
Normal file
@@ -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(' 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.** 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);
|
||||
@@ -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');
|
||||
<div class="site-footer__legal">
|
||||
<p>
|
||||
{brand.siteName} is free software under the{' '}
|
||||
<a href="https://www.gnu.org/licenses/gpl-3.0.html" rel="noopener noreferrer"
|
||||
>GPL-3.0-or-later</a
|
||||
>. © {year}.
|
||||
<a href={legal.licence.url} rel="noopener noreferrer">{legal.licence.id}</a>. ©
|
||||
{' '}{year}.
|
||||
<span class="site-footer__links">
|
||||
<a href="/privacy/">Privacy</a>
|
||||
<a href="/terms/">Terms</a>
|
||||
</span>
|
||||
</p>
|
||||
<p class="site-footer__meta">
|
||||
<span class="chip chip--version">Protocol {platform.protocol}</span>
|
||||
@@ -84,6 +89,16 @@ const isExternal = (href: string) => href.startsWith('http');
|
||||
</footer>
|
||||
|
||||
<style>
|
||||
/* Sits on the licence line rather than in a column of its own — see the header. The
|
||||
separator is a border so it never appears at the start of a wrapped line. */
|
||||
.site-footer__links {
|
||||
display: inline-flex;
|
||||
gap: 0.9rem;
|
||||
margin-left: 0.9rem;
|
||||
padding-left: 0.9rem;
|
||||
border-left: 1px solid var(--line-soft);
|
||||
}
|
||||
|
||||
.site-footer__meta {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
|
||||
@@ -41,6 +41,8 @@
|
||||
* cannot start, which is exactly what §1 forbids.
|
||||
*/
|
||||
|
||||
import { legal } from './legal.mjs';
|
||||
|
||||
/**
|
||||
* Google Play's closed-testing rules, as verified in the Play Console documentation on
|
||||
* **2026-08-24**.
|
||||
@@ -111,8 +113,12 @@ function readInt(name, fallback) {
|
||||
|
||||
/**
|
||||
* A label for the batch a row was written in. Stored in no column — see the header.
|
||||
*
|
||||
* Suffixed rather than re-dated when phase 6 added the age clause on the same day the
|
||||
* original wording was written: two different sentences must not share a label, and the
|
||||
* date is what an operator groups a CSV by.
|
||||
*/
|
||||
export const CONSENT_VERSION = '2026-08-24';
|
||||
export const CONSENT_VERSION = '2026-08-24b';
|
||||
|
||||
/**
|
||||
* The exact sentence beside the checkbox, and the exact sentence written to `consent_text`.
|
||||
@@ -121,12 +127,22 @@ export const CONSENT_VERSION = '2026-08-24';
|
||||
* the address is kept until the beta ends or removal is asked for, it is pasted into Play
|
||||
* because that is the only way Play accepts testers, and nothing is mailed to it because
|
||||
* the site cannot send mail at all (D7).
|
||||
*
|
||||
* Phase 6 added the age (D31), and it goes FIRST because it is the only clause the person
|
||||
* ticking the box is asserting rather than acknowledging — everything after it is a
|
||||
* description of what we do. `legal.minimumAge` is interpolated rather than typed, because
|
||||
* /privacy and /terms state the same number and the Data Safety notes answer a question
|
||||
* about it; four surfaces, one source.
|
||||
*
|
||||
* Editing this string is a real act: `consent_text` stores the wording rather than a
|
||||
* version, so rows written from here on carry the new sentence and older rows keep the one
|
||||
* they were given. That is the property that makes the column worth having.
|
||||
*/
|
||||
export const CONSENT_TEXT =
|
||||
'I understand my email address will be stored so it can be added to the Google Play ' +
|
||||
'closed test, that it will be shared with Google Play for that purpose only, that ' +
|
||||
'Runic Gateway sends no email of any kind, and that I can ask for it to be deleted at ' +
|
||||
'any time.';
|
||||
`I am ${legal.minimumAge} or older. I understand my email address will be stored so it ` +
|
||||
'can be added to the Google Play closed test, that it will be shared with Google Play ' +
|
||||
'for that purpose only, that Runic Gateway sends no email of any kind, and that I can ' +
|
||||
'ask for it to be deleted at any time.';
|
||||
|
||||
/**
|
||||
* What a tester needs, rendered as the page's eligibility list.
|
||||
@@ -136,6 +152,14 @@ export const CONSENT_TEXT =
|
||||
* a deployment, and a client with no server is not a product with a missing feature.
|
||||
*/
|
||||
export const requirements = [
|
||||
{
|
||||
title: `Being ${legal.minimumAge} or older`,
|
||||
body:
|
||||
'The beta is for adults. Nothing verifies it and nothing pretends to — ticking the ' +
|
||||
'box on the form is the whole of it — but it is the condition the list is collected ' +
|
||||
'under, and it is why the form needs no parental consent machinery it could not ' +
|
||||
'honestly operate.',
|
||||
},
|
||||
{
|
||||
title: 'An Android device on 10 or newer',
|
||||
body:
|
||||
|
||||
459
src/data/collection.mjs
Normal file
459
src/data/collection.mjs
Normal file
@@ -0,0 +1,459 @@
|
||||
/**
|
||||
* collection.mjs — what is collected, by whom, for how long. PLAN.md §9, built in phase 6.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY THE POLICY IS DATA AND NOT PROSE
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* §9 says the Play Data Safety declaration is "filled from section 2, and section 2 is
|
||||
* written knowing that is what it is for". Two documents saying the same thing about the
|
||||
* same code is the drift this repository already has two mechanisms against — the
|
||||
* capability list (D18) and the absences (D22) — and this is the worst instance of the
|
||||
* three, because the two readers are a published legal page and a form at Google that
|
||||
* cannot be corrected without a review round.
|
||||
*
|
||||
* So the inventory is one array. `/privacy` renders it as prose with the reasoning around
|
||||
* it; `scripts/playDataSafety.mjs` renders the `app`-scoped rows as the console's own
|
||||
* questions. Changing what the app stores means changing one row, and both move (D33).
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* THE THREE SCOPES, WHICH ARE THE WHOLE POINT OF THE PAGE
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* §9 is explicit that conflating them "would be wrong in both directions", and the
|
||||
* direction people miss is the second one:
|
||||
*
|
||||
* `site` This website. We are the data controller. It is one form.
|
||||
* `app` The Android app. We operate NO server it talks to — every byte goes to
|
||||
* a deployment the user typed the address of, run by whoever runs that
|
||||
* community. What is listed here is therefore mostly what the device
|
||||
* HOLDS, not what we receive, because we receive nothing.
|
||||
* `deployment` A self-hosted install of the platform. The operator is the controller,
|
||||
* not us. This scope exists so an operator sees the responsibility they
|
||||
* are taking on, and so a player never mistakes this policy for the one
|
||||
* governing their own community's site.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* EVERY ROW IS A FACT ABOUT CODE THAT EXISTS
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* Each entry names the file it was read out of. Not decoration: a privacy policy is the
|
||||
* document most likely to be written from a template and least likely to be re-read
|
||||
* against the software, and a row that cannot name its source is a row somebody guessed.
|
||||
* `checkFacts.mjs` cannot verify these — there is no version number to compare — so the
|
||||
* citation is what a reviewer uses instead.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} Retention
|
||||
* @property {string} summary Short enough to sit in a table cell.
|
||||
* @property {string} [detail] The mechanism, where the summary alone would be a promise.
|
||||
*
|
||||
* @typedef {object} PlayMapping
|
||||
* How this row answers Google Play's Data Safety form. `app` scope only — see
|
||||
* `scripts/playDataSafety.mjs`, which is the only reader.
|
||||
* @property {string} category The console's grouping, e.g. "Personal info".
|
||||
* @property {string} type The console's data type within that grouping.
|
||||
* @property {boolean} collected Does it leave the device to a server WE operate?
|
||||
* @property {boolean} shared Do we pass it to a third party?
|
||||
* @property {string} answer The recommended console answer, in one line.
|
||||
* @property {string} because Why that answer is the truthful one.
|
||||
*
|
||||
* @typedef {object} Collected
|
||||
* @property {string} id
|
||||
* @property {'site'|'app'|'deployment'} scope
|
||||
* @property {string} title
|
||||
* @property {string} body What it is and why it exists.
|
||||
* @property {Retention} retention
|
||||
* @property {string} source The file this was read out of, repo-relative.
|
||||
* @property {PlayMapping} [play]
|
||||
*
|
||||
* @type {Collected[]}
|
||||
*/
|
||||
export const collected = [
|
||||
/* =====================================================================================
|
||||
1. THIS WEBSITE
|
||||
|
||||
One form, and nothing else. There is no session, no cookie and no script — D9 is not
|
||||
a policy statement here, it is a description of the build output: the site sets no
|
||||
cookie of any kind, and `Base.astro` loads no third-party origin, so there is nothing
|
||||
to disclose beyond the row below and the access log the proxy keeps.
|
||||
===================================================================================== */
|
||||
{
|
||||
id: 'beta-email',
|
||||
scope: 'site',
|
||||
title: 'Your email address',
|
||||
body:
|
||||
'The one thing this site asks anybody for. It is stored so it can be pasted into ' +
|
||||
'the Google Play tester list, which is the only way Play accepts testers for a ' +
|
||||
'closed test. It is not mailed to — this site cannot send email at all — it is not ' +
|
||||
'sold, and it is used for nothing else.',
|
||||
retention: {
|
||||
summary: 'Until the beta ends, or until you ask',
|
||||
detail:
|
||||
'A removal erases the address itself rather than flagging the row: what is left ' +
|
||||
'behind is a date and the fact that a removal happened, which is what lets us ' +
|
||||
'answer “did you action my request” without keeping the thing you asked us to ' +
|
||||
'let go of.',
|
||||
},
|
||||
source: 'src/lib/betaStore.mjs',
|
||||
},
|
||||
{
|
||||
id: 'beta-consent',
|
||||
scope: 'site',
|
||||
title: 'The wording you agreed to, and when',
|
||||
body:
|
||||
'The exact sentence beside the checkbox is stored with the row, along with the ' +
|
||||
'date. A record of consent that cannot reproduce the words somebody actually ' +
|
||||
'agreed to is not a record of consent, and the wording can change over time.',
|
||||
retention: { summary: 'For the life of the row' },
|
||||
source: 'src/data/beta.mjs',
|
||||
},
|
||||
{
|
||||
id: 'beta-ip-hash',
|
||||
scope: 'site',
|
||||
title: 'A one-way hash of your IP address — never the address',
|
||||
body:
|
||||
'The form has to survive a script, and the cheapest defence is a limit per source. ' +
|
||||
'What is stored is a salted SHA-256 of the address, with the salt held in the ' +
|
||||
"server’s environment rather than in the database — so a copy of the file, on its " +
|
||||
'own, cannot be turned back into a list of who signed up from where. Rate limiting ' +
|
||||
'works perfectly well against a hash. Identifying somebody does not.',
|
||||
retention: {
|
||||
summary: 'With the row; the rate-limit log is pruned after 48 hours',
|
||||
detail:
|
||||
'A removal blanks the hash along with the address. Separately, the record of ' +
|
||||
'attempts the limiter counts against prunes itself on every write.',
|
||||
},
|
||||
source: 'src/lib/betaStore.mjs',
|
||||
},
|
||||
{
|
||||
id: 'beta-user-agent',
|
||||
scope: 'site',
|
||||
title: 'Your browser’s user-agent string, truncated',
|
||||
body:
|
||||
'The browser identifies itself on every request anyway; this one is kept beside the ' +
|
||||
'signup because it is the only signal that separates a person from a script after ' +
|
||||
'the fact. It is truncated, because the column is a signal rather than a transcript.',
|
||||
retention: { summary: 'With the row; blanked on removal' },
|
||||
source: 'src/lib/betaStore.mjs',
|
||||
},
|
||||
{
|
||||
id: 'site-access-log',
|
||||
scope: 'site',
|
||||
title: 'The web server’s access log',
|
||||
body:
|
||||
'Ordinary reverse-proxy logging, the same as any web server keeps: the IP address ' +
|
||||
'the request came from, the path, the user agent and the time. It is read when ' +
|
||||
'something is broken or being attacked, and it is not aggregated, profiled or ' +
|
||||
'joined to anything else. Nobody analyses this traffic, because there is nothing ' +
|
||||
'here that would benefit from it (D9, D30).',
|
||||
retention: {
|
||||
summary: 'Short-term operational retention, then rotated away',
|
||||
detail:
|
||||
'The log belongs to the reverse proxy on the host rather than to this ' +
|
||||
'application, so it is the proxy’s rotation that governs it. The site is on ' +
|
||||
'DNS-only records: no CDN or edge provider terminates the connection, so this log ' +
|
||||
'is the whole of it.',
|
||||
},
|
||||
source: 'PLAN.md §13 phase 12 — the operator note',
|
||||
},
|
||||
|
||||
/* =====================================================================================
|
||||
2. THE ANDROID APP
|
||||
|
||||
The unusual part, and the part §9 says must be stated precisely: we operate no server
|
||||
the app talks to. `ConnectScreen.kt` gates the entire app on an address the user
|
||||
enters and the app validates; everything below either stays on the phone or goes to
|
||||
that address. There is no telemetry SDK, no crash reporter and no analytics in the
|
||||
build — the manifest asks for INTERNET, network state, notifications and a data-sync
|
||||
foreground service, and nothing else.
|
||||
|
||||
`play` is filled in on every row here, because a row in this scope with no mapping is
|
||||
a question on the console form that somebody will answer from memory (D33).
|
||||
===================================================================================== */
|
||||
{
|
||||
id: 'app-session-tokens',
|
||||
scope: 'app',
|
||||
title: 'Your sign-in tokens',
|
||||
body:
|
||||
'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.',
|
||||
retention: {
|
||||
summary: 'On the device until you sign out',
|
||||
detail: 'Signing out clears them; uninstalling the app removes them with it.',
|
||||
},
|
||||
source: 'core/auth/EncryptedTokenStore.kt',
|
||||
play: {
|
||||
category: 'Personal info',
|
||||
type: 'User IDs',
|
||||
collected: false,
|
||||
shared: false,
|
||||
answer: 'Not collected by us.',
|
||||
because:
|
||||
'The credentials are issued by, and returned to, a server the user nominated. ' +
|
||||
'Nothing reaches an endpoint under our control, because we run none.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'app-trust-token',
|
||||
scope: 'app',
|
||||
title: 'The trusted-device token, if you asked for one',
|
||||
body:
|
||||
'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.',
|
||||
retention: {
|
||||
summary: 'On the device until it expires or you revoke it',
|
||||
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.',
|
||||
},
|
||||
source: 'core/auth/EncryptedTrustTokenStore.kt',
|
||||
play: {
|
||||
category: 'Personal info',
|
||||
type: 'User IDs',
|
||||
collected: false,
|
||||
shared: false,
|
||||
answer: 'Not collected by us.',
|
||||
because:
|
||||
'Same as the session tokens: minted by the user’s deployment, stored on the ' +
|
||||
'device, presented back to that same deployment.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'app-server-address',
|
||||
scope: 'app',
|
||||
title: 'The address of the deployment you chose',
|
||||
body:
|
||||
'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.',
|
||||
retention: { summary: 'On the device until you change it or uninstall' },
|
||||
source: 'core/prefs/ServerPreferences.kt',
|
||||
play: {
|
||||
category: 'App info and performance',
|
||||
type: 'Other app data',
|
||||
collected: false,
|
||||
shared: false,
|
||||
answer: 'Not collected by us. Stored on the device only.',
|
||||
because:
|
||||
'It never leaves the phone. It is the destination of requests, not the contents ' +
|
||||
'of one.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'app-push',
|
||||
scope: 'app',
|
||||
title: 'Push registration, if you turn notifications on',
|
||||
body:
|
||||
'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.',
|
||||
retention: {
|
||||
summary: 'Until you turn push off, sign out, or uninstall',
|
||||
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.',
|
||||
},
|
||||
source: 'core/push/NtfyTopic.kt, core/push/PushPreferences.kt',
|
||||
play: {
|
||||
category: 'Messages',
|
||||
type: 'Other in-app messages',
|
||||
collected: false,
|
||||
shared: false,
|
||||
answer:
|
||||
'Not collected by us. Declare the relay hop in the console’s free-text ' +
|
||||
'security section if it asks.',
|
||||
because:
|
||||
'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.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'app-content',
|
||||
scope: 'app',
|
||||
title: 'Everything you read and post in the app',
|
||||
body:
|
||||
'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.',
|
||||
retention: {
|
||||
summary: 'Held by the deployment, under its operator’s policy',
|
||||
},
|
||||
source: 'PLAN.md §9 section 2',
|
||||
play: {
|
||||
category: 'Messages',
|
||||
type: 'Other user-generated content',
|
||||
collected: false,
|
||||
shared: false,
|
||||
answer: 'Not collected by us.',
|
||||
because:
|
||||
'Content is written to the community’s own installation. We have no copy, no ' +
|
||||
'access and no way to obtain one.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'app-no-analytics',
|
||||
scope: 'app',
|
||||
title: 'No analytics, no crash reporting, no advertising',
|
||||
body:
|
||||
'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.',
|
||||
retention: { summary: 'Nothing to retain' },
|
||||
source: 'app/build.gradle.kts, app/src/main/AndroidManifest.xml',
|
||||
play: {
|
||||
category: 'Device or other IDs',
|
||||
type: 'Device or other IDs',
|
||||
collected: false,
|
||||
shared: false,
|
||||
answer: 'Not collected.',
|
||||
because:
|
||||
'No advertising ID, no analytics identifier, and no library that would generate ' +
|
||||
'one is linked into the build.',
|
||||
},
|
||||
},
|
||||
|
||||
/* =====================================================================================
|
||||
3. SELF-HOSTED DEPLOYMENTS
|
||||
|
||||
Written for an operator deciding what they are taking on, and for a player who found
|
||||
this page from their community's site and needs to be told, plainly, that it is not
|
||||
the policy governing them.
|
||||
===================================================================================== */
|
||||
{
|
||||
id: 'deploy-accounts',
|
||||
scope: 'deployment',
|
||||
title: 'Account records',
|
||||
body:
|
||||
'A username, a password hash, an optional email address, the role, and — where the ' +
|
||||
'operator has enabled it — a two-factor secret and single-use recovery codes. The ' +
|
||||
'time and IP address of the last sign-in are stored on the account row.',
|
||||
retention: { summary: 'Set by the operator; nothing expires on its own' },
|
||||
source: 'website server/db/schema.sql — users',
|
||||
},
|
||||
{
|
||||
id: 'deploy-sessions',
|
||||
scope: 'deployment',
|
||||
title: 'Sessions, devices and revocations',
|
||||
body:
|
||||
'Web sessions, mobile refresh tokens, trusted devices and the revocation list. A ' +
|
||||
'trusted-device row keeps a hash of the token, a device label, a truncated user ' +
|
||||
'agent and the times it was created and last used.',
|
||||
retention: {
|
||||
summary: 'Trusted devices expire after 30 days; refresh tokens rotate',
|
||||
},
|
||||
source: 'website server/db/schema.sql — trusted_devices, mobile_refresh_tokens',
|
||||
},
|
||||
{
|
||||
id: 'deploy-audit',
|
||||
scope: 'deployment',
|
||||
title: 'An audit log, with IP addresses on it',
|
||||
body:
|
||||
'Administrative and security-relevant actions are logged with the acting account, ' +
|
||||
'what was done, and the IP address it came from. This is the record a moderator ' +
|
||||
'relies on, and it is also the most sensitive thing in the database.',
|
||||
retention: { summary: 'Kept until the operator removes it' },
|
||||
source: 'website server/db/schema.sql — activity_log',
|
||||
},
|
||||
{
|
||||
id: 'deploy-bot-scoring',
|
||||
scope: 'deployment',
|
||||
title: 'Bot scoring and temporary IP bans',
|
||||
body:
|
||||
'Scanner traffic and failed logins raise a score against the source address, and a ' +
|
||||
'high enough score bans it from the site for an hour. Worth stating precisely ' +
|
||||
'because it is better than it sounds: that score lives in memory in the running ' +
|
||||
'process, not in the database, and it decays after half an hour of quiet — a ' +
|
||||
'restart forgets every address it was watching.',
|
||||
retention: {
|
||||
summary: 'In memory only; scores decay, bans last an hour',
|
||||
},
|
||||
source: 'website server/src/middleware/botScore.js',
|
||||
},
|
||||
{
|
||||
id: 'deploy-content',
|
||||
scope: 'deployment',
|
||||
title: 'Everything posted on the site',
|
||||
body:
|
||||
'Forum threads and replies, uploads, wiki revisions, moderation actions, warnings, ' +
|
||||
'reports and appeals — with the account that made each one. Where the operator has ' +
|
||||
'connected Discord, some of that crosses into Discord and is then also subject to ' +
|
||||
'Discord’s own terms.',
|
||||
retention: {
|
||||
summary: 'Operator-configured; some sweeps run on a retention window',
|
||||
detail:
|
||||
'Soft-deleted forum uploads and Team activity have retention windows an admin ' +
|
||||
'sets; most other content is kept until somebody removes it.',
|
||||
},
|
||||
source: 'website server/db/schema.sql — team_forum_*, mod_actions, content_reports',
|
||||
},
|
||||
{
|
||||
id: 'deploy-game-data',
|
||||
scope: 'deployment',
|
||||
title: 'Game data from the connected server',
|
||||
body:
|
||||
'Where a game module is installed, information about characters, guilds, houses and ' +
|
||||
'the in-game economy flows from the game server to the site through the bridge. ' +
|
||||
'Which of it is visible to the public is the operator’s decision, made in the ' +
|
||||
'admin panel — the bridge itself forwards, and the site decides.',
|
||||
retention: { summary: 'Operator-configured' },
|
||||
source: 'docs/link/v4.md — the visibility framework',
|
||||
},
|
||||
];
|
||||
|
||||
/** The entries in one scope, in file order. */
|
||||
export function collectedIn(scope) {
|
||||
return collected.filter((entry) => entry.scope === scope);
|
||||
}
|
||||
|
||||
/**
|
||||
* The `app` rows that carry a Play mapping — the Data Safety generator's input.
|
||||
*
|
||||
* A separate accessor rather than a filter at the call site, so the invariant below has
|
||||
* somewhere to live: every `app` row MUST map, because the form asks about the app as a
|
||||
* whole and a row nobody mapped is a question answered from memory.
|
||||
*/
|
||||
export function playRows() {
|
||||
const rows = collectedIn('app');
|
||||
const unmapped = rows.filter((entry) => !entry.play).map((entry) => entry.id);
|
||||
|
||||
if (unmapped.length) {
|
||||
throw new Error(
|
||||
`src/data/collection.mjs: app-scoped entries with no Play mapping: ${unmapped.join(', ')}.\n` +
|
||||
'\nEvery app row answers a question on the Data Safety form (D33). Add a `play`\n' +
|
||||
'block, or move the entry to another scope if it is not about the app.\n'
|
||||
);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fails the build when a scope renders nothing.
|
||||
*
|
||||
* The same guard `notBuilt.mjs` carries, for a stronger reason: an empty section on a
|
||||
* privacy policy does not read as an omission, it reads as "we collect nothing here", and
|
||||
* that is a claim nobody made.
|
||||
*/
|
||||
export function assertScopeNonEmpty(scope) {
|
||||
if (collectedIn(scope).length) return;
|
||||
|
||||
throw new Error(
|
||||
`src/data/collection.mjs has no entry in scope "${scope}", but /privacy renders it.\n` +
|
||||
'\nA section with nothing under it reads as a claim that nothing is collected.\n'
|
||||
);
|
||||
}
|
||||
53
src/data/legal.mjs
Normal file
53
src/data/legal.mjs
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* legal.mjs — the handful of values the legal pages and the signup form must agree on.
|
||||
* PLAN.md §9, built in phase 6.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHY THESE THREE THINGS ARE HERE AND NOT IN THE PAGES
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* Each is stated in more than one place and would be wrong in exactly one of them:
|
||||
*
|
||||
* `minimumAge` /terms says it, /privacy repeats it, the consent sentence beside the
|
||||
* signup checkbox commits somebody to it, and the Play Data Safety notes
|
||||
* answer a question about it. Four surfaces, one number (D31).
|
||||
* `lastUpdated` A legal page with no date is a legal page nobody can reason about, and
|
||||
* two pages with different dates invites the reader to work out which one
|
||||
* is stale. They changed together; they say so together.
|
||||
* `licence` Quoted on /terms and in the footer.
|
||||
*
|
||||
* The contact address is deliberately NOT here. It is a `brand.json` field read through
|
||||
* `src/lib/brand.mjs` (D13), so that changing the published address stays a file copy on a
|
||||
* mount rather than an edit to the source — and `checkFacts.mjs` fails the build if one is
|
||||
* typed into any file under `src/`.
|
||||
*/
|
||||
|
||||
export const legal = {
|
||||
/**
|
||||
* The date the legal pages last changed, in the format they render it.
|
||||
*
|
||||
* Bump it in the same commit that changes what either page says. It is not generated
|
||||
* from git: a build timestamp would move on every rebuild and tell a reader nothing,
|
||||
* and a commit date would move when a stylesheet changed.
|
||||
*/
|
||||
lastUpdated: '2026-08-24',
|
||||
|
||||
/**
|
||||
* The minimum age to sign up for the beta. The org lead's decision, 2026-08-24 (D31).
|
||||
*
|
||||
* Eighteen, chosen over thirteen and sixteen: it is above the children's-consent
|
||||
* threshold in every EEA state, so consent works as a basis with no parental-consent
|
||||
* machinery — which this form has no way to obtain and no way to verify. It is the
|
||||
* simplest thing to state truthfully for a beta that needs twelve people.
|
||||
*
|
||||
* A number rather than a sentence because four surfaces render it. What the site can
|
||||
* actually enforce is a statement, not a check, and every one of those surfaces is
|
||||
* written to say so plainly rather than implying verification that does not happen.
|
||||
*/
|
||||
minimumAge: 18,
|
||||
|
||||
/** The licence, quoted on /terms and in the footer. */
|
||||
licence: {
|
||||
id: 'GPL-3.0-or-later',
|
||||
url: 'https://www.gnu.org/licenses/gpl-3.0.html',
|
||||
},
|
||||
};
|
||||
@@ -390,7 +390,9 @@ const formToken = issueFormToken();
|
||||
<p class="beta-form__foot">
|
||||
Stored: the address, the wording above, the date, and a one-way hash of your
|
||||
connection used only to rate-limit this form. Never your IP address itself.
|
||||
Ask in Discord to have it deleted and it will be.
|
||||
Ask to have it deleted and it will be erased. The{' '}
|
||||
<a href="/privacy/">privacy page</a> says all of this in full, including how
|
||||
to ask.
|
||||
</p>
|
||||
</form>
|
||||
|
||||
|
||||
395
src/pages/privacy.astro
Normal file
395
src/pages/privacy.astro
Normal file
@@ -0,0 +1,395 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import PageHeader from '../components/PageHeader.astro';
|
||||
|
||||
import { assertScopeNonEmpty, collectedIn } from '../data/collection.mjs';
|
||||
import { legal } from '../data/legal.mjs';
|
||||
import { brand } from '../lib/brand.mjs';
|
||||
|
||||
/**
|
||||
* `/privacy` — PLAN.md §9, built in phase 6. The URL given to Google Play.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* THREE SCOPES, NEVER MERGED
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* §9's structure is the substance of the page rather than its layout. Runic Gateway is
|
||||
* self-hosted software, so "we" means three different parties depending on which sentence
|
||||
* you are reading, and a policy that blurred them would be wrong in both directions at
|
||||
* once: it would claim responsibility for data we cannot see, and it would let a player
|
||||
* believe this page governs the community site they actually use.
|
||||
*
|
||||
* So the page is three separately-scoped sections with the boundary stated in each, and
|
||||
* the rows come from `src/data/collection.mjs` — the same array `scripts/playDataSafety.mjs`
|
||||
* answers the console form from (D33). A published policy and a Play declaration that
|
||||
* disagree is the failure this repository already builds machinery against elsewhere.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* NO ADDRESS IN THIS FILE
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* The contact route is `brand.contactEmail`, read from the mounted `brand.json`. D13
|
||||
* publishes a personal address on the promise that replacing it with `privacy@` later
|
||||
* costs one file copy, and `checkFacts.mjs` fails the build if an address is typed into any
|
||||
* source file. This is the page most likely to want to — a privacy policy is where an
|
||||
* address belongs — which is exactly why the rule has to hold here.
|
||||
*/
|
||||
const title = 'Privacy';
|
||||
const description =
|
||||
'What this site collects, what the Android app holds on your device, and what a ' +
|
||||
'self-hosted deployment is responsible for.';
|
||||
|
||||
/* A section with nothing under it reads as a claim rather than an omission. */
|
||||
for (const scope of ['site', 'app', 'deployment']) assertScopeNonEmpty(scope);
|
||||
|
||||
const sections = [
|
||||
{
|
||||
id: 'this-site',
|
||||
number: 1,
|
||||
heading: 'This website',
|
||||
controller: 'We are responsible for this section.',
|
||||
lede:
|
||||
'There are no cookies, no analytics, no tracking scripts and no third-party ' +
|
||||
'requests of any kind — not as a policy we promise to keep, but as a description of ' +
|
||||
'what the pages load. The only thing this site ever asks you for is an email ' +
|
||||
'address for the Android beta, and only if you choose to give one.',
|
||||
rows: collectedIn('site'),
|
||||
},
|
||||
{
|
||||
id: 'the-app',
|
||||
number: 2,
|
||||
heading: 'The Android app',
|
||||
controller: 'We operate no server the app talks to.',
|
||||
lede:
|
||||
'This is the part that makes the app unusual, and it is worth reading rather than ' +
|
||||
'skimming. The app ships pointed at nothing: on first run it asks for the address ' +
|
||||
'of a Runic Gateway site and nothing else in the app works until one is entered and ' +
|
||||
'validated. That site is run by whoever runs that community. Everything you do in ' +
|
||||
'the app happens between your phone and their server, and there is no account with ' +
|
||||
'us, no service of ours in the middle, and no copy of anything on our side — because ' +
|
||||
'we do not operate one.',
|
||||
rows: collectedIn('app'),
|
||||
},
|
||||
{
|
||||
id: 'deployments',
|
||||
number: 3,
|
||||
heading: 'Self-hosted deployments',
|
||||
controller: 'The operator of that deployment is responsible, not us.',
|
||||
lede:
|
||||
'Runic Gateway is software people install on their own machines. If you play on a ' +
|
||||
'community that runs it, your account lives on their server, under their control ' +
|
||||
'and their policy — this page is not it. What follows is an inventory of what the ' +
|
||||
'software collects, so that an operator can see plainly what they are taking on, ' +
|
||||
'and a player can see what to ask their operator about.',
|
||||
rows: collectedIn('deployment'),
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<Base title={title} description={description}>
|
||||
<PageHeader eyebrow="Privacy" title="Who holds what, and for how long">
|
||||
<p>
|
||||
Written from what the code does rather than from a template — every entry below was
|
||||
read out of the file that implements it, and the file is named. It is deliberately
|
||||
specific in the places a policy is usually vague, because the vague places are the
|
||||
ones that matter.
|
||||
</p>
|
||||
<p>
|
||||
Three sections, because there are three different answers to “who has this”. Read the
|
||||
one that applies to you; the boundaries between them are real.
|
||||
</p>
|
||||
</PageHeader>
|
||||
|
||||
<section class="page section legal-meta">
|
||||
<p class="legal-meta__line">
|
||||
<span class="chip chip--version">Last updated {legal.lastUpdated}</span>
|
||||
<span class="legal-meta__age">You must be {legal.minimumAge} or older to sign up for the beta.</span>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<nav class="page section legal-toc" aria-label="Sections">
|
||||
<ol>
|
||||
{
|
||||
sections.map((section) => (
|
||||
<li>
|
||||
<a href={`#${section.id}`}>
|
||||
<span class="legal-toc__n">{section.number}</span>
|
||||
<span>
|
||||
<strong>{section.heading}</strong>
|
||||
<span class="legal-toc__who">{section.controller}</span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
{
|
||||
sections.map((section) => (
|
||||
<section class="page section legal-sec" id={section.id}>
|
||||
<div class="legal-sec__head">
|
||||
<p class="eyebrow">Section {section.number}</p>
|
||||
<h2>{section.heading}</h2>
|
||||
<p class="legal-sec__who">{section.controller}</p>
|
||||
<p class="prose legal-sec__lede">{section.lede}</p>
|
||||
</div>
|
||||
|
||||
<ul class="legal-rows">
|
||||
{section.rows.map((row) => (
|
||||
<li class="panel legal-row">
|
||||
<h3>{row.title}</h3>
|
||||
<p class="legal-row__body">{row.body}</p>
|
||||
<p class="legal-row__keep">
|
||||
<span class="legal-row__keep-label">How long</span>
|
||||
{row.retention.summary}
|
||||
{row.retention.detail && (
|
||||
<span class="legal-row__keep-detail">{row.retention.detail}</span>
|
||||
)}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))
|
||||
}
|
||||
|
||||
<section class="page section legal-sec" id="your-choices">
|
||||
<div class="legal-sec__head">
|
||||
<p class="eyebrow">Section 4</p>
|
||||
<h2>Removing your address, and asking questions</h2>
|
||||
<p class="legal-sec__who">This applies to section 1 only — the beta list.</p>
|
||||
<p class="prose legal-sec__lede">
|
||||
We hold one piece of information about you and it is the address you typed into the
|
||||
beta form. Ask for it to be removed and it will be erased rather than marked: what
|
||||
stays behind is a date and the fact that a removal happened, so we can confirm we
|
||||
did it without keeping the thing you asked us to let go of.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="panel legal-panel">
|
||||
<ul class="legal-ways">
|
||||
<li>
|
||||
<h3>By email</h3>
|
||||
<p>
|
||||
Say which address to remove. There is no form to fill in and no account to prove
|
||||
— knowing the address is all that is needed, because it is all that is stored.
|
||||
</p>
|
||||
<a class="btn btn--ghost" href={`mailto:${brand.contactEmail}`}>{brand.contactEmail}</a>
|
||||
</li>
|
||||
<li>
|
||||
<h3>On Discord</h3>
|
||||
<p>
|
||||
The same request works in the <a href="/community/">community Discord</a>, which
|
||||
is generally the faster of the two.
|
||||
</p>
|
||||
<a class="btn btn--ghost" href={brand.discordInvite} rel="noopener noreferrer">
|
||||
Join the Discord
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p class="legal-panel__note">
|
||||
One consequence of erasing rather than flagging, stated because it is the honest
|
||||
reading and not a caveat we would rather you missed: afterwards the list cannot tell
|
||||
your address from one it has never seen. Asking twice gets the same answer as asking
|
||||
about a stranger, and signing up again later is an ordinary new signup.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="page section legal-sec" id="changes">
|
||||
<div class="legal-sec__head">
|
||||
<p class="eyebrow">Section 5</p>
|
||||
<h2>Changes to this page</h2>
|
||||
<p class="prose legal-sec__lede">
|
||||
If what the software collects changes, this page changes with it in the same
|
||||
release — the entries above are generated from a single inventory in the source, so
|
||||
a change to what is stored and a change to what this page says are the same edit.
|
||||
The date at the top is the last time that happened. This site sends no email at all,
|
||||
so there is no notification to send you when it does; the page itself is the record.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
/* ---- The header strip -------------------------------------------------- */
|
||||
|
||||
.legal-meta {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.legal-meta__line {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.legal-meta__age {
|
||||
color: var(--dim);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* ---- Contents ---------------------------------------------------------- */
|
||||
|
||||
.legal-toc ol {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 17rem), 1fr));
|
||||
}
|
||||
|
||||
.legal-toc a {
|
||||
display: flex;
|
||||
gap: 0.85rem;
|
||||
height: 100%;
|
||||
padding: 0.9rem 1rem;
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: var(--radius-panel);
|
||||
background: var(--panel-flat);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.legal-toc a:hover {
|
||||
border-color: var(--gold-deep);
|
||||
}
|
||||
|
||||
.legal-toc__n {
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
border: 1px solid var(--gold-deep);
|
||||
border-radius: var(--radius-pill);
|
||||
color: var(--gold);
|
||||
font-family: var(--display);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.legal-toc__who {
|
||||
display: block;
|
||||
margin-top: 0.2rem;
|
||||
color: var(--dim);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ---- A section --------------------------------------------------------- */
|
||||
|
||||
.legal-sec__head {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.legal-sec h2 {
|
||||
margin: 0 0 0.4rem;
|
||||
font-size: clamp(1.5rem, 3vw, 2rem);
|
||||
}
|
||||
|
||||
/* The boundary sentence. Gold, because on this page it is the load-bearing line of
|
||||
each section rather than a subtitle — a reader who takes only one sentence from a
|
||||
section should take this one. */
|
||||
.legal-sec__who {
|
||||
margin: 0 0 0.75rem;
|
||||
color: var(--gold);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.legal-sec__lede {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.legal-rows {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 21rem), 1fr));
|
||||
}
|
||||
|
||||
.legal-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: clamp(1.1rem, 3vw, 1.5rem);
|
||||
}
|
||||
|
||||
.legal-row h3 {
|
||||
margin: 0 0 0.55rem;
|
||||
font-size: 1.06rem;
|
||||
}
|
||||
|
||||
/* Deliberately NOT `flex: 1`, which is what the cards elsewhere on the site use to
|
||||
line their buttons up. These bodies differ in length by a factor of four — the hash
|
||||
entry earns its paragraph, the consent entry needs two sentences — and pushing the
|
||||
retention line to the bottom of the tallest card in the row opened a void in the
|
||||
middle of the short ones that read as missing content rather than as alignment.
|
||||
Caught by looking at the built page, which is the only thing that catches it. */
|
||||
.legal-row__body {
|
||||
margin: 0 0 1.1rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.legal-row__keep {
|
||||
margin: 0;
|
||||
padding-top: 0.9rem;
|
||||
border-top: 1px solid var(--line-soft);
|
||||
color: var(--dim);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.legal-row__keep-label {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.11em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.legal-row__keep-detail {
|
||||
display: block;
|
||||
margin-top: 0.45rem;
|
||||
}
|
||||
|
||||
/* ---- The two panels at the foot ---------------------------------------- */
|
||||
|
||||
.legal-panel {
|
||||
padding: clamp(1.25rem, 4vw, 2.25rem);
|
||||
}
|
||||
|
||||
.legal-ways {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 19rem), 1fr));
|
||||
}
|
||||
|
||||
.legal-ways h3 {
|
||||
margin: 0 0 0.4rem;
|
||||
font-size: 1.02rem;
|
||||
}
|
||||
|
||||
.legal-ways p {
|
||||
margin: 0 0 1rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.legal-panel__note {
|
||||
margin: 1.75rem 0 0;
|
||||
padding-top: 1.25rem;
|
||||
border-top: 1px solid var(--line-soft);
|
||||
max-width: var(--measure);
|
||||
color: var(--dim);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
259
src/pages/terms.astro
Normal file
259
src/pages/terms.astro
Normal file
@@ -0,0 +1,259 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import PageHeader from '../components/PageHeader.astro';
|
||||
|
||||
import { legal } from '../data/legal.mjs';
|
||||
import { brand } from '../lib/brand.mjs';
|
||||
|
||||
/**
|
||||
* `/terms` — PLAN.md §9, built in phase 6.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHAT THIS PAGE IS ALLOWED TO GOVERN
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* §9: "short and honest… it does not attempt to govern anyone's self-hosted deployment,
|
||||
* because it cannot." That sentence is the whole design. Three things are in scope — this
|
||||
* website, the beta list, and the APK we publish — and the software itself is governed by
|
||||
* its licence rather than by anything written here. A terms page that quietly claimed
|
||||
* authority over every installation of a GPL program would be both unenforceable and
|
||||
* contrary to the licence it ships under, and it is the single most common thing a
|
||||
* generated template gets wrong.
|
||||
*
|
||||
* No governing-law clause, by the org lead's decision on 2026-08-24 (D32). Nothing of
|
||||
* value is contracted for here: the site sells nothing, the software is free under a
|
||||
* licence that carries its own terms, and the beta is a list of addresses somebody asked
|
||||
* to be on. A jurisdiction clause on a page like this is decoration, and this site does not
|
||||
* write decoration into a legal page.
|
||||
*
|
||||
* The contact address is `brand.contactEmail` and appears nowhere in this file (D13).
|
||||
*/
|
||||
const title = 'Terms';
|
||||
const description =
|
||||
'What this site is, what the beta is, and what the licence governs — short, and only ' +
|
||||
'about the things we actually run.';
|
||||
|
||||
const clauses = [
|
||||
{
|
||||
id: 'software',
|
||||
heading: 'The software is free, and its licence governs it',
|
||||
body: [
|
||||
'Everything this site describes — the website, the bridge, the game plugin, the ' +
|
||||
'installer, the module and the Android app — is free software released under the ' +
|
||||
`${legal.licence.id}. That licence is what governs your use of it: what you may do ` +
|
||||
'with it, what you must do if you distribute it, and the fact that it comes with ' +
|
||||
'no warranty.',
|
||||
'Nothing on this page adds to it, subtracts from it, or applies alongside it. If ' +
|
||||
'this page and the licence ever appear to disagree about the software, the licence ' +
|
||||
'is right.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'deployments',
|
||||
heading: 'We do not govern anyone’s deployment, and cannot',
|
||||
body: [
|
||||
'If you run this software, the site you run is yours. We have no access to it, no ' +
|
||||
'control over it and no relationship with the people using it — you set its rules ' +
|
||||
'and you carry its responsibilities, including for the personal data it holds.',
|
||||
'If you play on a community that runs it, your agreement is with that community, ' +
|
||||
'not with us. These terms are not the terms of the site you are actually using, ' +
|
||||
'and this is not the place to appeal a ban.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'site',
|
||||
heading: 'This website is informational, and offered as it is',
|
||||
body: [
|
||||
'The pages here describe software and how to run it. We try hard to keep them ' +
|
||||
'accurate — versions and protocol numbers on this site are re-read from the ' +
|
||||
'repositories on every build, precisely so they cannot quietly go stale — but the ' +
|
||||
'site is provided without warranty of any kind, and a decision to run this software ' +
|
||||
'in production is yours.',
|
||||
'Do not attack it, scrape it into the ground, or use it to attack anything else. ' +
|
||||
'That is the whole of the acceptable-use policy for a site with one form on it.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'beta',
|
||||
heading: 'The beta is a beta',
|
||||
body: [
|
||||
`Signing up asks for one thing: an email address, given by somebody ${legal.minimumAge} ` +
|
||||
'or older. We take it as given that you meet that — there is no verification, and ' +
|
||||
'saying so plainly is better than implying a check nobody performs.',
|
||||
'The list is used to add testers to a Google Play closed test and for nothing else. ' +
|
||||
'A place on it is not a promise: the test may be delayed, changed, restricted or ' +
|
||||
'abandoned, the app may break in ways a released app would not, and being on the ' +
|
||||
'list does not guarantee an invitation. Ask to be removed at any time and the ' +
|
||||
'address is erased.',
|
||||
'Do not sign somebody else up, and do not put a script on the form. The limits are ' +
|
||||
'modest and the list is small enough that abuse costs a real person their place.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'apk',
|
||||
heading: 'The app you download here is the app we built',
|
||||
body: [
|
||||
'Until the app is on Google Play, the download on this site links straight at a ' +
|
||||
'signed release we publish, with a checksum file beside it. Check it if you like — ' +
|
||||
'that is what it is for.',
|
||||
'It is a pre-release build, it is not distributed by a store, and it comes with the ' +
|
||||
'same absence of warranty as the rest. An APK from anywhere other than our own ' +
|
||||
'releases is not ours, whatever it is called.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'changes',
|
||||
heading: 'If this page changes',
|
||||
body: [
|
||||
'The date at the top is the last time it did. This site sends no email of any kind, ' +
|
||||
'so there is no notice to send — the page is the record, and it changes in the ' +
|
||||
'same release as whatever prompted it.',
|
||||
],
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<Base title={title} description={description}>
|
||||
<PageHeader eyebrow="Terms" title="Short, and only about what we run">
|
||||
<p>
|
||||
Three things belong to us: this website, the list of people who asked to test the
|
||||
Android app, and the app builds we publish. Those are what this page covers.
|
||||
</p>
|
||||
<p>
|
||||
The software itself is covered by its licence, and a community’s own site is covered
|
||||
by that community. Saying so is not a disclaimer — it is the accurate description of
|
||||
a program people run on their own machines.
|
||||
</p>
|
||||
</PageHeader>
|
||||
|
||||
<section class="page section legal-meta">
|
||||
<p class="legal-meta__line">
|
||||
<span class="chip chip--version">Last updated {legal.lastUpdated}</span>
|
||||
<a class="legal-meta__link" href="/privacy/">What we collect is on the privacy page</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="page section">
|
||||
<ol class="terms-list">
|
||||
{
|
||||
clauses.map((clause, index) => (
|
||||
<li class="panel terms-clause" id={clause.id}>
|
||||
<p class="terms-clause__n">{String(index + 1).padStart(2, '0')}</p>
|
||||
<div class="terms-clause__body">
|
||||
<h2>{clause.heading}</h2>
|
||||
{clause.body.map((paragraph) => (
|
||||
<p>{paragraph}</p>
|
||||
))}
|
||||
</div>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section class="page section">
|
||||
<div class="panel terms-foot">
|
||||
<p class="eyebrow">Questions</p>
|
||||
<h2>There is a person at the other end</h2>
|
||||
<p>
|
||||
Anything about this page, the beta list, or a security problem you would rather not
|
||||
discuss in public goes to the same address — or ask in the{' '}
|
||||
<a href="/community/">Discord</a>, which is faster for everything except the last one.
|
||||
</p>
|
||||
<div class="terms-foot__actions">
|
||||
<a class="btn btn--ghost" href={`mailto:${brand.contactEmail}`}>{brand.contactEmail}</a>
|
||||
<a class="btn btn--ghost" href={legal.licence.url} rel="noopener noreferrer">
|
||||
Read the {legal.licence.id}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
/* Shared with /privacy in spirit but not in stylesheet — Astro scopes component styles,
|
||||
and two legal pages are not enough repetition to justify a global. The header strip is
|
||||
the one piece both render identically. */
|
||||
|
||||
.legal-meta {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.legal-meta__line {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.legal-meta__link {
|
||||
color: var(--dim);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.terms-list {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.terms-clause {
|
||||
display: flex;
|
||||
gap: clamp(1rem, 3vw, 2rem);
|
||||
padding: clamp(1.25rem, 3.5vw, 2rem);
|
||||
}
|
||||
|
||||
/* The numeral is the page's only ornament, and it earns its place: these clauses are
|
||||
referred to by number in conversation, and a list with no visible numbers cannot be. */
|
||||
.terms-clause__n {
|
||||
flex: none;
|
||||
margin: 0;
|
||||
color: var(--gold-deep);
|
||||
font-family: var(--display);
|
||||
font-size: clamp(1.5rem, 4vw, 2.1rem);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.terms-clause__body {
|
||||
max-width: var(--measure);
|
||||
}
|
||||
|
||||
.terms-clause h2 {
|
||||
margin: 0 0 0.7rem;
|
||||
font-size: clamp(1.15rem, 2.5vw, 1.35rem);
|
||||
}
|
||||
|
||||
.terms-clause p {
|
||||
margin: 0 0 0.85rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.97rem;
|
||||
}
|
||||
|
||||
.terms-clause p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.terms-foot {
|
||||
padding: clamp(1.25rem, 4vw, 2.25rem);
|
||||
}
|
||||
|
||||
.terms-foot h2 {
|
||||
margin: 0 0 0.7rem;
|
||||
font-size: clamp(1.4rem, 3vw, 1.8rem);
|
||||
}
|
||||
|
||||
.terms-foot p {
|
||||
max-width: var(--measure);
|
||||
margin: 0 0 1.5rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.terms-foot__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
153
test/legal.test.mjs
Normal file
153
test/legal.test.mjs
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* The legal pages' data, tested where a mistake would be invisible. PLAN.md §9, phase 6.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHAT IS ACTUALLY AT RISK HERE
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* A privacy policy is prose, and prose is not testable. What IS testable is the small set
|
||||
* of structural promises the page and the Play declaration both rest on, every one of
|
||||
* which fails silently:
|
||||
*
|
||||
* - a row with no retention line renders a card with an empty "How long" — which reads
|
||||
* as "we keep this forever" or "we keep nothing", depending on the reader;
|
||||
* - an `app`-scoped row with no Play mapping means a question on the console form gets
|
||||
* answered from memory, which is the exact failure D33 exists to prevent;
|
||||
* - a row that claims we collect or share something contradicts the premise the whole
|
||||
* page rests on, and would be a real disclosure defect rather than a typo;
|
||||
* - the consent sentence and /terms stating different minimum ages, which is the kind of
|
||||
* inconsistency a reviewer finds and a developer never does.
|
||||
*
|
||||
* None of that shows up in a build, a type check or a link check: the page renders
|
||||
* beautifully with an empty cell and a wrong number in it.
|
||||
*
|
||||
* The generated Play document is checked by `scripts/playDataSafety.mjs --check` rather
|
||||
* than here — a generator's output is a build artefact, and comparing it in two places
|
||||
* means fixing it in two places.
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import {
|
||||
assertScopeNonEmpty,
|
||||
collected,
|
||||
collectedIn,
|
||||
playRows,
|
||||
} from '../src/data/collection.mjs';
|
||||
import { legal } from '../src/data/legal.mjs';
|
||||
import { CONSENT_TEXT, CONSENT_VERSION, requirements } from '../src/data/beta.mjs';
|
||||
|
||||
const SCOPES = ['site', 'app', 'deployment'];
|
||||
|
||||
describe('the collection inventory', () => {
|
||||
it('has a row in every scope /privacy renders', () => {
|
||||
for (const scope of SCOPES) {
|
||||
assert.doesNotThrow(() => assertScopeNonEmpty(scope), `scope "${scope}" is empty`);
|
||||
}
|
||||
});
|
||||
|
||||
it('uses only the three scopes the page knows how to render', () => {
|
||||
for (const row of collected) {
|
||||
assert.ok(SCOPES.includes(row.scope), `${row.id} has unknown scope "${row.scope}"`);
|
||||
}
|
||||
});
|
||||
|
||||
it('gives every row a unique id', () => {
|
||||
const ids = collected.map((row) => row.id);
|
||||
assert.equal(new Set(ids).size, ids.length, 'duplicate id in collection.mjs');
|
||||
});
|
||||
|
||||
it('gives every row a retention summary and a source', () => {
|
||||
for (const row of collected) {
|
||||
assert.ok(row.retention?.summary?.trim(), `${row.id} has no retention summary`);
|
||||
assert.ok(row.source?.trim(), `${row.id} does not name the file it was read from`);
|
||||
assert.ok(row.title?.trim() && row.body?.trim(), `${row.id} is missing prose`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('the Play Data Safety mapping', () => {
|
||||
it('maps every app-scoped row', () => {
|
||||
assert.doesNotThrow(() => playRows());
|
||||
assert.equal(playRows().length, collectedIn('app').length);
|
||||
});
|
||||
|
||||
it('answers every mapped question completely', () => {
|
||||
for (const row of playRows()) {
|
||||
const { category, type, answer, because, collected: isCollected, shared } = row.play;
|
||||
assert.ok(category?.trim() && type?.trim(), `${row.id} has no console category/type`);
|
||||
assert.ok(answer?.trim() && because?.trim(), `${row.id} has no answer or reasoning`);
|
||||
assert.equal(typeof isCollected, 'boolean', `${row.id}.play.collected is not a boolean`);
|
||||
assert.equal(typeof shared, 'boolean', `${row.id}.play.shared is not a boolean`);
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* The one assertion here that is about the product rather than the shape of the data.
|
||||
*
|
||||
* "We operate no server the app talks to" is the premise of /privacy section 2, of the
|
||||
* generated declaration, and of the argument for why the app needs no account with us.
|
||||
* If that ever stops being true — a telemetry endpoint, a crash reporter, a hosted
|
||||
* directory of deployments — the honest change is a `collected: true` row AND a rewrite
|
||||
* of the page's second section. This test makes the first impossible without noticing
|
||||
* the second, by failing with the reason rather than the diff.
|
||||
*/
|
||||
it('holds the premise the whole section rests on', () => {
|
||||
for (const row of playRows()) {
|
||||
assert.equal(
|
||||
row.play.collected,
|
||||
false,
|
||||
`${row.id} says we collect it. If that is now true, /privacy section 2's premise — ` +
|
||||
'that we operate no server the app talks to — has changed, and the page has to ' +
|
||||
'change with it rather than gaining a row that contradicts its own lede.'
|
||||
);
|
||||
assert.equal(row.play.shared, false, `${row.id} says we share it — see above.`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('the minimum age', () => {
|
||||
it('is stated in the consent sentence the row records', () => {
|
||||
assert.match(
|
||||
CONSENT_TEXT,
|
||||
new RegExp(`\\b${legal.minimumAge}\\b`),
|
||||
'the consent text does not state the minimum age'
|
||||
);
|
||||
});
|
||||
|
||||
it('is stated in the eligibility list', () => {
|
||||
const ages = requirements.filter((entry) =>
|
||||
new RegExp(`\\b${legal.minimumAge}\\b`).test(entry.title)
|
||||
);
|
||||
assert.equal(ages.length, 1, 'the beta requirements should name the age exactly once');
|
||||
});
|
||||
|
||||
/*
|
||||
* Changing the wording without changing the label would leave two different sentences
|
||||
* sharing one version in the exported CSV, which is the only thing that label is for.
|
||||
*/
|
||||
it('was accompanied by a consent version bump', () => {
|
||||
assert.notEqual(
|
||||
CONSENT_VERSION,
|
||||
'2026-08-24',
|
||||
'the age clause changed CONSENT_TEXT; CONSENT_VERSION must not still be the label ' +
|
||||
'the pre-age wording was written under'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the published contact route', () => {
|
||||
/*
|
||||
* D13 in test form. `checkFacts.mjs` enforces this over the whole of `src/` and
|
||||
* `scripts/`, and it needs a network token to run — so it is skipped by anybody working
|
||||
* offline, on the two files most likely to want to type an address into.
|
||||
*/
|
||||
it('is not baked into the legal data', () => {
|
||||
const text = JSON.stringify({ collected, legal });
|
||||
assert.doesNotMatch(
|
||||
text,
|
||||
/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i,
|
||||
'an email address is hard-coded in the legal data — read brand.contactEmail instead'
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user