Twenty pages completing the tree section 10 planned: Modules (8), Architecture
(5) and Reference (7). Four decisions, D38-D41, recorded in PLAN.md section 10.
D39 is the one that shaped the phase. Section 1 forbids re-specifying a
contract, and a Reference section is exactly where that rule is most tempting to
break, so the line is drawn at names: every environment variable, config key,
installer command, visibility rung and canonical document is listed with one
terse line saying what it is FOR, while shapes, semantics and every "why" stay
in the canonical document.
That is only safe because the names are checked. checkReference.mjs compares six
enumerations against the repositories that own them, over the Gitea API, as set
comparisons in BOTH directions -- and the second direction is the one that earns
its keep, because a reference page does not usually rot by describing something
that vanished, it rots by quietly not mentioning what was added since.
The check went green on its first run, which is the least trustworthy possible
outcome, so it was verified by breaking it: seven mutations, all caught. The one
worth keeping is the visibility ladder REORDERED with its membership unchanged
-- it is a security boundary, and a set comparison alone would have passed it.
D41 turns plannedSidebar from a checklist into a checked invariant, and finding
out why was the phase's first defect: it had already drifted, because phase 7
added the Content page under D37 and never updated the list. Nothing failed,
because nothing read it. checkSidebar.mjs now asserts the two trees agree on
groups, labels and order -- order because the order of Getting started IS the
installation path.
Two more things the writing found. PLAN.md's page count was wrong and had been
since section 10 was written ("roughly 38, 37 planned" for a tree of forty).
And module.json's `mounts` and the SPA's paths are different mechanisms that no
single document stated plainly -- module-uo declares admin: ["/shard",
"/uo-link"] while its screen lives at /admin/uo/link, because API routes are
deliberately NOT namespaced while SPA routes are. That is precisely the
distinction the installer got wrong in v0.1.0, and it now has a named home.
D40: the docs link to /architecture/'s drawn diagrams rather than importing
them. Those components carry marketing chrome and depend on diagram.css, which
Starlight does not load; the docs use text diagrams, which paste into an issue.
npm run verify green: 40 pages across 5 groups agree with plannedSidebar, 2390
internal links resolve, 123 repository links point at a branch, 19 facts, 59
quickstart checks, 22 reference enumerations, astro check 0 errors, 36 tests.
Co-Authored-By: Claude <noreply@anthropic.com>
runicgateway.com
The public marketing and documentation site for Runic Gateway — the platform that puts a private game server's live state on a public website without ever exposing the game to the internet.
Two audiences: server administrators who want to understand and install the platform, and developers who want to build modules and integrations for it. A third arrives with the Android closed beta: players, who want the app.
The design of record is PLAN.md. It is not a sketch — it carries the verified
platform state, the org lead's decisions, the information architecture, and the build phases. Read
it before changing anything here.
Status: phase 5 of 12 — the app and the beta. The foundation, the branding pipeline, the
homepage and the five marketing pages are built, and /app/ and /beta/ now join them: a signed
APK beside the closed-test signup, backed by a SQLite store on a bind mount and an export CLI. Next
are the legal pages (phase 6) and then the documentation — the installation path, which is the
priority of the whole project — in phases 7 and 8.
Running it
npm install
npm run dev # http://localhost:4321
npm run build # → dist/ (prerendered pages + the Node server entry)
npm start # serve the built site
Node 22 LTS or newer.
The checks, and why they are not optional
Two of them, both from PLAN.md §12. Neither is a linter; each one enforces a promise the site
makes that would otherwise decay quietly.
npm run check:tokens # no colour literal outside the token file
npm run check:brand # the branding pipeline's two quiet failures
npm run check:datasafety # the Play declaration still matches /privacy
npm run check # astro check
npm test # the beta signup's decision path, and the policy data
npm run build && npm run check:links # every internal link resolves (reads the build)
GITEA_TOKEN=<token> npm run check:facts # every version agrees with its authority
npm run verify # all of the above, in that order
checkFacts.mjs re-reads every version, protocol number and bundle tag in
src/data/platform.json from its source of truth over the Gitea API — the sidecar's
PROTOCOL_VERSION, the overlay's overlay.toml, the website's MODULE_API_VERSION, the installer's
published bundle manifest, and each repository's latest release — and fails on any disagreement.
When the platform moves, this repository goes red so that someone updates the site. That failure is
the feature, the same mechanism and the same intent as the Integration Kit's checkCoreApi.js. §1
of the plan records what it is guarding against: an earlier draft confidently stated the platform was
on protocol 3, because every checkout in the workspace sat on a feature branch whose local main had
never been fetched.
It needs a token — anonymous raw fetches fail on this Gitea instance, and a check that silently skips
itself is worse than no check at all. It must be able to read the other repositories in the
organisation, not just this one. CI already has this: the workflow maps the org-level
REGISTRY_TOKEN secret into GITEA_TOKEN for that step.
checkTokens.mjs fails the build if a colour literal appears anywhere in src/ outside
src/styles/tokens.css. §7 promises that recolouring the site is a file copy and a container
restart; a mounted theme.css can only redefine custom properties, so a literal in a component is a
piece of the site an operator can never reach. Without the check, "one CSS file changes the
appearance" becomes "one CSS file changes most of the appearance".
checkBrand.mjs guards the two things about the branding pipeline that fail quietly. It puts
every literal /brand/... URL in the source through the route's own classifier, so a template
asking for a size that is not on the allowlist fails the build rather than 404ing in a browser; and
it refuses a brand string short enough that replacing it blindly at boot could corrupt a page.
checkLinks.mjs reads dist/client rather than src/, because half the links these pages
carry are assembled from data files and template literals and a source scan sees an expression. It
also refuses a commit permalink into any org repository — those stop tracking the document they name
without ever 404ing, which is the failure a link checker would otherwise call healthy.
npm test is the one check that reads none of the above. Everything else inspects built output,
and the beta signup's logic does not appear there: a honeypot can stop working entirely and produce
a build identical to one where it works. It covers the honeypot, the signed form token, the timing
window, the per-connection rate limit, the global cap, address validation, idempotent duplicates and
removal. Run the file by name — node --test test/ fails on Node 22, which is what CI uses.
The closed-beta signup
/beta is the only page that renders per request and the only one that writes anything. It handles
its own POST, so the form works with JavaScript disabled and every outcome renders in the real
layout. The store is SQLite on the data/ bind mount; the raw IP address is never recorded,
only a salted hash used to rate-limit.
There is no admin page, by design — the tester list is managed from a shell:
npm run beta -- stats # counts, and where the store lives
npm run beta -- export # a CSV record + a .txt to paste into Play; marks rows exported
npm run beta -- export -- --all # everything, including already-exported rows
npm run beta -- remove someone@example.com
| Variable | Default | What it does |
|---|---|---|
DATA_DIR |
./data |
The bind mount holding beta.sqlite and exports/ |
BETA_IP_SALT |
random per process | Salts ip_hash. Unset means rate limits reset on restart |
BETA_FORM_KEY |
random per process | Signs the form token, so a script must fetch the page before posting |
BETA_TOTAL_CAP |
500 |
Rows above which the form closes and says so |
BETA_PER_HOUR / BETA_PER_DAY |
3 / 24 |
Attempts one connection may make |
Neither random default is a placeholder to be replaced by a constant: a hard-coded salt would make every deployment's hashes identical and therefore reversible by anyone holding this repository.
Branding is bind-mounted data
brand-default/ is baked into the image and always complete. brand/ is the bind mount and may be
empty, partial or full. Every file resolves against the mount first and the defaults second, per
file, so overriding only theme.css leaves every logo stock and an empty mount produces exactly
the stock site. Nothing here goes through Vite, which would fingerprint the filenames into the build
and put them out of the mount's reach.
Rebranding is one file. brand-default/ holds a single raster — logo.png — and GET /brand/*
derives every size the site asks for from whichever logo.png is in force: the header mark at three
pixel ratios, the install icons, the apple-touch icon, the favicons and a real multi-resolution
favicon.ico. Drop in one file, restart, and the browser tab and the installed icon change with the
header.
Brand text is applied at boot. Pages are prerendered, so the site name, tagline and links are
baked into HTML that a mounted file cannot reach. npm start runs scripts/applyBrand.mjs first,
which rewrites the built HTML from what it last applied to what the mount now says — recorded in
dist/.brand-applied.json, so the second edit works as well as the first. An empty mount makes it a
no-op.
A mounted theme.css wins by cascade layer, not by link order. tokens.css is inside
@layer tokens; the mounted stylesheet is unlayered and therefore beats it wherever the browser
encounters it. Do not "fix" this by reordering the links — Astro emits its own stylesheet after the
head markup, which is what made the ordering approach silently useless.
To try it: put a theme.css, a logo.png or a brand.json in brand/, run npm run build and
npm start. curl -I any /brand/* URL and the X-Brand-Source header says which of mount,
defaults or derivation answered.
Regenerating the stock assets is a separate, manual step — npm run brand:assets — because it reads
the emblem and the Cinzel outlines from the sibling checkouts in the workspace. Its output is
committed so that CI never needs either.
Layout
src/
data/platform.json Every externally-sourced fact. No version is written in prose.
data/collection.mjs What is collected, in three scopes. /privacy renders it and the
Play Data Safety notes are generated from it — one inventory.
data/legal.mjs The values /terms, /privacy and the consent sentence must share.
styles/tokens.css THE token file — the only place a colour literal may appear.
styles/global.css The layout shell, built entirely from tokens.
styles/starlight.css Restates our tokens as Starlight's, so the docs cannot drift.
layouts/, components/ The marketing chrome.
pages/ Marketing routes.
pages/beta.astro The signup. Renders AND handles its own POST — runs per request.
content/docs/docs/ Documentation. The extra level mounts Starlight at /docs.
pages/brand/ GET /brand/* — the mount, resolved and derived. Runs per request.
lib/brand.mjs The single accessor for brand text, plus liveBrand() for the two
routes that render per request and so miss the boot rewrite.
lib/brandAssets.mjs Mount-first resolution and on-demand derivation.
lib/betaStore.mjs The SQLite store: schema, dedupe, rate-limit window, cap, removal.
lib/betaSignup.mjs Everything between a POST body and a row. Never throws.
lib/tokens.mjs Reads tokens.css at build time, for the few values that leave CSS.
config/sidebar.mjs The documentation journey, and the planned tree behind it.
brand-default/ The stock brand, baked into the image and always complete.
scripts/ The build-time checks, plus applyBrand (boot), brand:assets (manual)
and beta.mjs (the tester-list CLI).
test/ node --test. The logic the other checks cannot see.
PLAY_DATA_SAFETY.md GENERATED. The answers to Google Play's Data Safety form, from
src/data/collection.mjs. Edit the data, run npm run play:datasafety.
Two directories are bind mounts at runtime and are not in the repository: brand/ overrides
anything in brand-default/ per file, and data/ holds the beta signup store. See PLAN.md §6
and §7.
Contributing
Branch from main (feature/…, fix/…, docs/…, chore/…) and use
Conventional Commits. Run npm run verify before opening a
pull request.
AI-assisted contributions must be disclosed, per org policy: tick the box in the pull request
template naming the tool, and mark AI-authored commits with a trailer such as
Co-Authored-By: Claude <noreply@anthropic.com>. Undisclosed AI-generated contributions may be
closed.
Licence
GPL-3.0-or-later, in common with every repository in the organisation. See LICENSE.