feat(legal): phase 6 — the privacy policy and the terms
All checks were successful
PR checks / checks (pull_request) Successful in 55s
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:
@@ -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',
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user