Files
runicgateway.com/src/pages/privacy.astro
wtclaude a2faf07104
All checks were successful
PR checks / checks (pull_request) Successful in 55s
feat(legal): phase 6 — the privacy policy and the terms
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>
2026-08-24 04:21:47 -05:00

396 lines
13 KiB
Plaintext

---
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>