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:
@@ -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>
|
||||
Reference in New Issue
Block a user