feat(legal): phase 6 — the privacy policy and the terms
All checks were successful
PR checks / checks (pull_request) Successful in 55s

PLAN.md §9. Builds /privacy and /terms, links them from the footer on every page,
and generates the Play Data Safety notes from the same inventory the policy renders.

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

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-24 04:21:47 -05:00
parent 29c96d0b21
commit a2faf07104
15 changed files with 1810 additions and 18 deletions

459
src/data/collection.mjs Normal file
View 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 ' +
"servers 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 browsers 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 servers 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 proxys 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 deployments 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 users 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 ' +
'topics 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 consoles 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 operators 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 communitys 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 ' +
'Discords 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 operators 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'
);
}