Files
runicgateway.com/PLAN.md
wtclaude e8cb6061fe
All checks were successful
PR checks / checks (pull_request) Successful in 9m22s
fix(facts): installer v0.1.1 is released, so the note names a version
Recovering installer's orphan tag published v0.1.1, which moved the platform
under this branch and turned checkFacts red -- the check working exactly as it
should, since a version this site quotes had changed.

  FAIL  release installer
        platform.json says : v0.1.0
        installer releases/latest says : v0.1.1

The 500 that orphaned the tag was a race with the tag push one second earlier,
not a structural failure: re-running the workflow took the built-in orphan-tag
recovery path and published all four assets unchanged.

So the stale-path Aside stops saying "v0.1.0 is still the current download",
which is no longer true, and says the durable thing instead -- v0.1.0 prints
the old path, v0.1.1 prints the real one -- which stays correct however many
releases follow. platform.json and the PLAN.md version table move to v0.1.1,
and the phase 7 findings record the pipeline defect as a fourth finding.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 11:50:38 -05:00

1178 lines
80 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# runicgateway.com — design of record
**Status:** decisions taken by the org lead on 2026-08-19 and recorded in §5. **Phase 0 is
complete**; Phase 1 is the next build step.
**Date:** 2026-08-19 (revision 3)
**Repo:** `RunicGateway/runicgateway.com`
**AI-assisted:** yes — drafted by Claude, per the org's AI-usage disclosure policy.
The public website and documentation site for Runic Gateway. 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.
Revision 1 asked five open questions. Revision 2 records the answers as decisions and designs
against them. Revision 3 marks Phase 0 shipped and adds **D13**, which settles the contact address
§14 N2 had left open — the domain still has no mailbox, and the org lead chose to publish the
existing one rather than delay the beta.
---
## Contents
1. [The principle](#1-the-principle)
2. [Verified platform state](#2-verified-platform-state)
3. [What the repositories disagree about](#3-what-the-repositories-disagree-about)
4. [Phase 0 — fix the documentation the site will quote](#4-phase-0--fix-the-documentation-the-site-will-quote)
5. [Decisions of record](#5-decisions-of-record)
6. [Runtime shape](#6-runtime-shape)
7. [Branding is bind-mounted data](#7-branding-is-bind-mounted-data)
8. [The Android closed-beta signup](#8-the-android-closed-beta-signup)
9. [Legal pages](#9-legal-pages)
10. [Information architecture](#10-information-architecture)
11. [Visual direction](#11-visual-direction)
12. [Accuracy machinery](#12-accuracy-machinery)
13. [Build phases](#13-build-phases)
14. [Still needed from the org lead](#14-still-needed-from-the-org-lead)
15. [Planned, not in scope: the demo instance](#15-planned-not-in-scope-the-demo-instance)
---
## 1. The principle
**The site is a presentation layer over existing project knowledge, never a competing source of
technical truth.**
Concretely:
- The normative contracts stay where they are and are **linked, never copied**:
[`MODULE_API.md`][api], [`MODULE_SYSTEM.md`][system], [`link/INTEGRATION.md`][int],
[`link/v3.md`][v3], [`link/v4.md`][v4], [`website/TEAMS.md`][teams],
[`website/BACKEND_DESIGN.md`][backend], [`installer/INSTALL.md`][install].
- The site authors the **journey** — install, configure, administer, extend — which is the thing no
existing document owns end to end, because the repos are organised by component and an operator
is not.
- Where marketing language and technical documentation conflict, **the technical documentation
wins**, and the marketing language changes.
- No feature is advertised that does not exist. §2 records what does; the "not built" list is as
load-bearing as the rest.
### A process rule, learned the hard way
An earlier draft of this report stated the platform was on **protocol 3**, with protocol 4
"feature-complete on `edge`, not cut over". That was wrong — protocol 4 is on `main` and released.
The cause: all nine checkouts in the workspace sat on feature branches whose local `main` ref had
**never been fetched**, so `git show main:<path>` answered confidently from a months-old blob. Local
`main` was 13 commits behind `origin/main` in seven of nine repos.
**Rule for this repo:** every version, protocol number or capability claim that reaches the website
is verified against `origin/<default-branch>` or the Gitea API *at the moment it is written*, never
against a local working tree. §12 makes that a build-time check rather than a promise, because a
promise is exactly what failed.
---
## 2. Verified platform state
All values re-read from the Gitea API on **2026-08-19**, after revision 1.
### Versions
| Component | Value | Authority |
|---|---|---|
| **Wire protocol** | **4** | `link` `main:sidecar/src/main.rs:55` `PROTOCOL_VERSION` |
| **Module API** | **1.6.0** | `website` `main:server/src/modules/version.js` |
| **Current bundle** | **2026.08.19** (protocol 4, generated 09:05:52Z) | `installer` branch `bundles``current.json` |
| uo-link sidecar | **v2.0.0** (2026-08-19) | release; in bundle 2026.08.19 |
| Plugin overlay | **v1.0.0** (2026-08-19) | release; in bundle 2026.08.19 |
| Installer | **v0.1.1** (2026-08-24) | release |
| `module-uo` | **v1.0.1** (2026-08-19) | release |
| Android app | **v0.5.0** (2026-08-08), id `com.runicgateway.app` | release; `app/build.gradle.kts` |
| ServUO | **57.4** — min version, and the only version the patch tier is verified against | bundle `overlay.servuo` |
| `website` | **no releases** — ships as container images, never tagged | Gitea releases API (empty) |
Three corrections to revision 1, found while re-verifying:
- **The bundle manifests live at the root of the `bundles` branch**, not under `bundles/`:
`current.json`, `bundle-2026.08.19.json`. §12's fact-checker must fetch the root path.
- **`website` publishes no releases at all.** The site must never print a "website version"; it
refers to the platform by bundle and Module API version instead.
- **The Android app targets no server of ours.** `core/net/BaseUrlHolder.kt`,
`core/net/HostSelectionInterceptor.kt` and `core/prefs/ServerPreferences.kt` mean the user enters
the address of the deployment they belong to. This is load-bearing for §9.
### What exists
Confirmed present on `main`, not inferred from design docs.
**Core (game-agnostic):** accounts and sessions; wiki; posts across four categories (news,
five-on-friday, newsletter, screenshots); CMS pages and a page builder; navigation editor;
appearance/theming; branding as data via `BRAND_*`; invites; moderation and appeals; content
reports; append-only activity log; bot-scoring with automatic IP bans; TOTP two-factor with trusted
devices and recovery codes; SSO over OAuth2/OIDC (Google, Discord, generic — **link-only**, never
auto-provisioned); mobile bearer auth with rotated hashed refresh tokens; push through self-hosted
**ntfy**; the module loader and the admin Modules screen; OpenAPI 3.0 at `/api/docs`.
**Teams** — merged to `main` (PR #161, cutover 4/6). Team core and roster; forums with
announcements, discussion threads, replies, an edit window, post moderation and member-raised abuse
reports; notifications across web, push and email; Discord slash commands; one voice channel per
Team, granted by role.
**`module-uo` (72 routes)** — capabilities `shard · atlas · market · governors · guilds · houses ·
champs · cliloc`: shard status, economy, houses and IDOC decay, character sheets, the player-vendor
marketplace, a spawn atlas built from the shard's own spawn tables, champion boards, points/loyalty
leaderboards, town crier, account linking, and the staff control consoles.
**The bridge** — ServUO plugin → loopback TCP `127.0.0.1:7788` (newline-delimited JSON, the shard
dials out) → uo-link sidecar (Rust, SQLite) → website over authenticated WebSocket + REST. The
installer deploys the shard side as a protocol-checked bundle and registers a service under an
unprivileged account.
### What does NOT exist — and will not be advertised
- **Matrix.** [`TEAMS.md`][teams] §8 is explicit research, not a roadmap item: Matrix has no
channel-with-overwrites, no role object, no voice channel (voice is a MatrixRTC session needing a
LiveKit SFU the homeserver does not ship), and no slash-command registration. Of the five
capabilities a shared interface would name, a Matrix implementation could honestly provide two.
The settled outcome was a **capability contract**, not an integration. The original brief for this
site listed Matrix as a feature; it is not one.
- **Multi-module deployments.** [`TEAMS.md`][teams] Part 1 puts this explicitly out of scope — one
active module per deployment. `module_id` columns exist to keep it later-friendly; nothing
exercises them.
- **A second game module.** `module-rust` is a paper dry-run (`docs/modules/rust-dryrun.md`),
deliberately unimplemented — it exists to test that the contract generalises. Its absence is also
the exit criterion for the Integration Kit's draft status (§5, D8).
- **A finished Integration Kit.** It describes itself as a draft: "finished when someone outside
this project builds a working module for a new game by following it alone. That has not happened
yet."
- **macOS and Windows-on-ARM installer builds.** Deliberately absent — the shard and sidecar must
share a host, and no ServUO host is either.
- **A public demo instance.** Planned (§15), not built, and not linked until it is.
---
## 3. What the repositories disagree about
Verified against `origin/main`. None of these are caused by this project; all of them would be
**inherited and amplified** by a website that quotes them. All ten are now in scope — see §5, D3.
| # | Conflict | Location | Severity |
|---|---|---|---|
| 1 | **`INTEGRATION.md` contradicts itself.** Its event catalog fully documents the protocol-4 kinds — `guild.roster`, `guild.leave`, a subsection headed "On Protocol 4" — while its normative §2 still declares `X-UOLink-Version: 3`, `"protocol": 3` in `/health` and `ws.hello`, and a worked client example sending `"3"`. | `docs/link/INTEGRATION.md` lines 36, 5863, 95, 118, 1015 | **High** — this is the contract a third-party integrator implements against |
| 2 | **`INSTALL.md` still tells operators protocol 3.** Line 413 is the "Protocol version 3" value in the four-value handoff an operator pastes into Admin → Shard. The document's own troubleshooting table says a stale number is answered with `409` and "looks exactly like your shard going offline". | `docs/installer/INSTALL.md` lines 159, 163, 164, 413, 516, 726, 862 | **High** — actively misconfigures a real install |
| 3 | **`v4.md` says the cutover has not happened.** "Status: Feature-complete on `edge``edge``main` is the v4 cutover" — but link v2.0.0 and overlay v1.0.0 are released from `main` and bundle 2026.08.19 pairs them at protocol 4. | `docs/link/v4.md` line 3 | Medium |
| 4 | **The org landing page prints protocol 3** in the installer's four-value handoff block. | `.profile/README.md` line 179 | Medium |
| 5 | **Stale component versions throughout `INSTALL.md`** — bundle `2026.08.04`, overlay `v0.1.1`, sidecar `1.1.0`, including in copy-pasteable `curl` commands in Appendix A. | `docs/installer/INSTALL.md` lines 119, 512, 522523, 657658, 705706, 725, 862 | Medium |
| 6 | **Two architecture diagrams disagree.** `docs/website/ARCHITECTURE.md` places `shardIngest.js` and `uoLinkClient.js` *inside* the website backend; `website/README.md` places them in the module. ARCHITECTURE.md predates the module system. | `docs/website/ARCHITECTURE.md` | Medium |
| 7 | **`website-README.md` has drifted from the live README** — the snapshot lacks "Three ways in, and none of them is a build" (admin panel / `MODULES` env / by hand), which is now the primary module-install story. | `docs/website/website-README.md` | Low |
| 8 | **`docs/README.md`'s index is stale** — no entry for `TEAMS.md`, `ARCHITECTURE.md`, `TRUSTED_DEVICES_MFA.md` or `MODERATION_APPEALS.md`. | `docs/README.md` | Low |
| 9 | **`BACKEND_DESIGN.md` is titled "UOMysticmoon Website — Backend Design"** although core is game-agnostic and UO is a module. | `docs/website/BACKEND_DESIGN.md` | Low |
| 10 | **`Module-uo/README.md` shows Phase 4 as `⬜`** though the module system shipped 2026-08-12 and the admin Modules screen exists. | `Module-uo/README.md` line 41 | Low |
| 11 | **Product name spelling.** All prose is "Runic Gateway"; identifiers are `RunicGateway`. **Settled by the org lead: keep "Runic Gateway" in prose.** Recorded because the site's original brief mandated the opposite. | everywhere | Settled |
Also found while re-verifying, and not a documentation conflict but a real constraint on §10:
> **`gitea.whitlocktech.com` has registration disabled.** Repositories are publicly readable and
> `/explore` answers anonymously, but the sign-up page returns "Registration is disabled". Nobody
> outside the org can open an issue. See §14, N3.
---
## 4. Phase 0 — fix the documentation the site will quote
Conflicts 15 are protocol-accuracy bugs that exist independently of this website, and the site
cannot be written honestly on top of them: "Connect a game server" has to tell an operator which
number to type into Admin → Shard, and today the canonical guide says the wrong one. Conflicts
610 are hygiene; the org lead elected to fix them in the same pass.
**Phase 0 lands before the site quotes anything.** Each is a separate PR in its own repo, using the
verified values from §2.
| PR | Repo | Change |
|---|---|---|
| 0.1 | `docs` | `link/INTEGRATION.md` — protocol 3 → 4 in §2 and every normative example (`X-UOLink-Version`, `/health`, `ws.hello`, the JS client). Resolves the self-contradiction with its own protocol-4 event catalog. |
| 0.2 | `docs` | `link/v4.md` — status line: the `edge``main` cutover is done; released as link v2.0.0 + overlay v1.0.0 in bundle 2026.08.19. |
| 0.3 | `docs` | `installer/INSTALL.md` — protocol 3 → 4 and the stale component versions → bundle 2026.08.19 / sidecar 2.0.0 / overlay 1.0.0, including Appendix A's `curl` commands. |
| 0.4 | `.profile` | `README.md` — protocol 3 → 4 in the handoff block; the `module-uo` release link moves off `v0.3.0`. |
| 0.5 | `docs` | `website/ARCHITECTURE.md` — the diagram moves `shardIngest.js` and `uoLinkClient.js` into the module, matching `website/README.md` and the module system as shipped. |
| 0.6 | `docs` | `website/website-README.md` — re-sync the snapshot with the live README, including "Three ways in, and none of them is a build". |
| 0.7 | `docs` | `README.md` — index entries for `TEAMS.md`, `ARCHITECTURE.md`, `TRUSTED_DEVICES_MFA.md`, `MODERATION_APPEALS.md`. |
| 0.8 | `docs` | `website/BACKEND_DESIGN.md` — retitle for the game-agnostic core. |
| 0.9 | `Module-uo` | `README.md` — Phase 4 marked complete. |
| ~~0.10~~ | ~~`docs`~~ | ~~`SECURITY.md` — the public vulnerability contact moves from a personal Gmail to `security@runicgateway.com`.~~ **Withdrawn 2026-08-19 by D13.** The domain has no mailbox and the org lead elected to publish the existing address rather than wait; `SECURITY.md` already carries it and needs no change. |
0.50.9 are grouped where they touch one repo, so the real PR count is smaller than the row count;
`docs` PRs 0.50.8 land as one hygiene PR.
**Nothing in Phase 0 changes code or a contract.** Each PR corrects documentation to match what the
code already does, and each cites the source of truth in its description.
**Phase 0 is complete.** It shipped on 2026-08-19 as four merged PRs — `docs`#172 (0.10.3),
`docs`#173 (0.50.8), `.profile`#4 (0.4), `Module-uo`#19 (0.9) — plus 0.10 withdrawn as above.
Every fact the site is about to quote now agrees across the repositories.
---
## 5. Decisions of record
Taken by the org lead (Colby Whitlock) on 2026-08-19. Recorded so they are not re-litigated.
| # | Decision | Consequence |
|---|---|---|
| **D1** | **Runtime: Astro + Node adapter, hybrid.** Pages prerendered; a small number of real server endpoints exist. One Node container. | §6. Makes both the bind-mounted branding and the signup form possible without a second service. |
| **D2** | **Beta signup: our own form → our own store → CSV into Play.** Beta purpose only, no announce list. | §8. Granular consent is unnecessary; the policy states one purpose. |
| **D3** | **All ten documentation conflicts are fixed**, not just the operator-facing five. | §4. |
| **D4** | **Real web screenshots**, captured from the local review stack, not placeholders. | §13 phase 6. Needs seeded, presentable demo content. |
| **D5** | **Claude drafts `/privacy` and `/terms`** from what the code actually collects; the org lead reviews before ship. | §9. |
| **D6** | **Ship the image and compose file; the org lead deploys.** DNS and TLS terminate at their existing reverse proxy. | §13 phase 9. This repo never touches the production host. |
| **D7** | **The site sends no email at all.** No SMTP, no notifications, no mailbox behind the domain yet. | §8 designs the signup so it works anyway — see "The opt-in link removes the need for email". A contact address is still required; D13 supplies it. |
| **D8** | **Understated honesty.** The site reads as finished; factual badges appear only where they save a reader wasted effort. **The Integration Kit stays marked draft until a second module is successfully built against it.** | §11, §10. A status with an exit criterion, not a mood. |
| **D9** | **No analytics.** No tracking scripts, no third-party requests, no cookie banner. | Reverse-proxy access logs are the only traffic data. |
| **D10** | **Support routes to Discord and Gitea issues.** Invite: `https://discord.gg/t2Jav8yT4g`. | §10. Discord is the front door; Gitea takes issues from anyone with an account, which today is nobody outside the org — §14, N3. |
| **D11** | **Keep the existing emblem.** `runic-emblem.png` is the mark on both the website and the Android launcher icon; the site adopts it rather than drawing a new one. | §11. Revision 1's "draw a new geometric mark" recommendation is withdrawn. |
| **D12** | **A public demo instance is planned but out of scope today** — a Proxmox VM running the full stack including ServUO, with restricted settings and an hourly automatic reset. | §15. The IA reserves the slot so it lands later without a restructure. |
| **D13** | **Publish the existing address.** No mailbox has been created at the domain and the org lead elected not to wait for one: `whitlocktech@gmail.com` is the published contact wherever the site needs one — `/privacy`, `/terms`, `/community`, the Play listing — and `docs/SECURITY.md` keeps the address it already carries. | Taken 2026-08-19, superseding §14 N2 as a blocker. The address lives **only** in `brand.json` (§7), never in prose, so moving to `privacy@`/`security@` later is a file edit and a restart. Phase 0's PR 0.10 is withdrawn, not deferred. |
**Decisions after D13 are recorded where they were taken**, in the section describing the phase that
raised them, rather than appended here — a decision is only re-litigated when its reasoning is
somewhere other than the thing it decided. The count of record is **thirty-seven**:
| # | Where | What it settled |
|---|---|---|
| D14D16 | §7, "How phase 2 actually built it" | The branding pipeline: one raster in, brand text applied at boot, the mark is the real emblem |
| D17D19 | §10, "How phase 3 built the homepage" | The data-path diagram, all five groups on the homepage, the emblem-led hero |
| D20D25 | §10, "How phase 4 built the marketing pages" | `/features/` as the same list with detail, `/architecture/` as reasons not reference, the absences as data, the two absorbed scope items, `needsModule`, the demo deep links |
| D26D29 | §8, "How phase 5 built the app and the beta" | The screenshot slot reserved for phase 9, the demo as the tester target, `/beta` handling its own POST, equal billing for the APK and the beta |
| D30D33 | §9, "How phase 6 built the legal pages" | One logging hop and no edge provider, eighteen or older, no governing-law clause, the Data Safety notes as a generated document |
| D34D37 | §10, "How phase 7 built the documentation journey" | One PR for all twenty pages, a self-contained install quickstart with a drift check, every admin screen walked before it was described, a thirteenth Administration page for content |
---
## 6. Runtime shape
**Astro with the Node adapter, `output: 'server'` with per-page `prerender = true`.** Every
marketing and documentation page is prerendered HTML at build time; a handful of endpoints under
`/api/` and the `/brand/*` asset route are the only things that execute per request.
Why not fully static: two requirements need a process on the box. The beta signup must accept a
POST and write it somewhere (§8), and branding must be overridable by dropping a file into a bind
mount **without rebuilding the image** (§7) — which means the bytes cannot be fingerprinted into
the build output.
*Amended 2026-08-24 by D28: the signup is `/beta` itself rather than a `POST /api/beta-signup`
endpoint. The dynamic surface is still two routes and the reasoning above is unchanged; see §8.*
Why not a separate API service: one container is one thing to deploy, one thing to patch, and one
log to read. The dynamic surface is three endpoints.
```
┌──────────────────────────── runicgateway.com container ────────────────────────────┐
│ │
│ Astro (Node adapter) │
│ ├── prerendered pages ......... marketing, docs, legal — plain HTML │
│ ├── GET /brand/* ............ reads the bind mount, falls back to defaults │
│ └── GET + POST /beta/ ......... renders the form; writes SQLite on the mount │
│ │
│ /app/brand-default ..... baked into the image (stock logo, tokens, brand.json) │
└─────────┬──────────────────────────────────────────────┬───────────────────────────┘
│ bind mount │ bind mount
./brand → /app/brand ./data → /app/data
logo, favicon, theme.css, brand.json beta.sqlite, exports/
```
**Stack:** Astro for the marketing pages, Starlight for `/docs` (sidebar, breadcrumbs,
previous/next, automatic table of contents, offline full-text search). Vite underneath, so it stays
inside the org's existing tooling family. Node 22 LTS.
**Security posture**, matching the rest of the org:
- A strict CSP with no external origins. Self-hosted fonts, no CDN, no analytics (D9), so
`default-src 'self'` holds with no exceptions to argue about.
- The only writing endpoint is the signup, and it is rate-limited (§8).
- **No authenticated surface exists on the site at all.** The CSV export is a CLI run against the
bind mount, not an HTTP route — see §8.
---
## 7. Branding is bind-mounted data
The requirement: swapping a logo or recolouring the site is a file copy and a container restart —
never a rebuild, never an image push. This mirrors the product's own posture (`BRAND_*` env,
appearance/theming in the admin panel), so someone who has themed a Runic Gateway deployment
already knows how to theme this site.
### The mechanism
Two directories. `/app/brand-default` is baked into the image and always complete.
`/app/brand` is the bind mount and may be empty, partial, or full. **Every asset 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 the stock site exactly.
`GET /brand/*` serves them at stable, unhashed URLs with an ETag and a short cache TTL. These files
are deliberately **not** imported through Vite, because Vite would fingerprint the filename into
the build and the mount could never replace them.
| File | Purpose |
|---|---|
| `logo.png` / `logo.svg` | The emblem — header mark, hero |
| `wordmark.svg` | Optional horizontal lockup (emblem + "Runic Gateway"); falls back to emblem + type |
| `favicon.ico`, `icon-192.png`, `icon-512.png` | Browser and install icons |
| `og-image.png` | Link preview card |
| `theme.css` | **Custom-property overrides only** — appended last, so it wins |
| `brand.json` | Text and links: site name, tagline, Discord invite, Gitea org URL, contact address |
`brand.json` exists so that renaming the product, changing the Discord invite or adding a contact
address does not require a rebuild either — the same class of change as swapping a logo.
### How phase 2 actually built it
Three decisions taken during the build (org lead, 2026-08-20). They refine the mechanism above
rather than change what it promises.
**D14 — one raster in, every size out.** Only `logo.png`, `wordmark.svg`, `og-image.png`,
`theme.css` and `brand.json` are baked into `brand-default/`. Every other image in the table above
— all the logo sizes, both install icons, the apple-touch icon, the favicons and the `.ico` — is
**derived at request time** from whichever `logo.png` is in force, cached in memory, and limited to
an allowlist of sizes. Precomputing them would have meant an operator producing fifteen files to
change a mark, and the realistic outcome of that is a deployment with a new header and the old
favicon. "A file copy" now means one file.
**D15 — brand text is applied at boot, not at render.** §6 prerenders every page, so a value read
at build time is baked into HTML the mount cannot reach; §7 promises otherwise. `npm start` runs
`scripts/applyBrand.mjs` before the server opens a socket, rewriting the built HTML from what was
baked to what the mount says. Every page stays prerendered, Pagefind still has static HTML to index,
and the documentation is covered by the same pass as the marketing pages. The alternatives — server
-rendering the brand-bearing pages, which is the whole site because of the footer, or accepting
build-time text — were rejected. The script rewrites from a **record of what it last applied**
rather than from the defaults, because the naive version works exactly once and then silently
ignores every later edit.
**D16 — the mark is the real emblem** (D11 carried through). The header shows `runic-emblem.png`,
not phase 1's placeholder glyph, so the site, the product and the Android launcher icon are one
mark. The cost, accepted: it is raster art, so `theme.css` cannot recolour it — changing the mark
means replacing `logo.png`.
### The rule that keeps the promise true
**Every colour, radius, shadow and font in the site's stylesheet is a CSS custom property defined in
one file.** `theme.css` in the mount only ever redefines those properties. A `scripts/checkTokens.mjs`
fails the build if a colour literal appears anywhere outside the token definition file.
Without that check, "one CSS file changes the appearance" decays into "one CSS file changes most of
the appearance, and then there is a hardcoded `#0e1318` in the footer". The check is the mechanism;
diligence is not.
`scripts/checkBrand.mjs` is the second half of it, added in phase 2: it fails the build if
`brand-default/` is incomplete, if any `/brand/*` URL in the source would 404 against the route's
own allowlist, or if a brand string is short enough that replacing it blindly at boot could corrupt
a page.
**The mounted stylesheet wins by cascade layer, not by link order.** `tokens.css` is wrapped in
`@layer tokens` and `theme.css` is unlayered, so the mount takes precedence wherever the browser
encounters it. The first attempt relied on `theme.css` being linked last, and it did not work:
Astro emits its own stylesheet after the head markup, so the site's tokens landed after the
operator's and every override was silently a no-op.
Token names deliberately match `website/client/src/styles/theme.css` where the concepts line up
(`--bg`, `--panel-a`, `--accent`, `--ink`, `--line`, `--radius-card`, …), so a theme written for one
is legible in the other.
---
## 8. The Android closed-beta signup
### What Google Play actually requires
Established before designing, because it constrains everything:
- Closed testing accepts testers as **pasted email lists** (up to 200 lists, 2,000 addresses each,
50 lists per track) **or as a Google Group**. There is **no API to add an individual tester**
any form we build ends in a human pasting a CSV.
- Every tester must **opt in themselves** through a web opt-in link, whichever method is used.
Being on the list is necessary and not sufficient.
- An individual (non-organisation) developer account needs **12 testers opted in continuously for
14 days** before production access. That number is a design target, not a footnote — the page
should be written to convert.
- The store listing requires a **privacy policy URL** and a **contact email address** (§9, §14).
*These are current as of 2026-08-19 and should be re-checked in the Play Console before launch;
Play's testing requirements have changed more than once.*
### The opt-in link removes the need for email
D7 says the site sends no email. That looks fatal — with the email-list method, Google does not
notify testers; the developer distributes the opt-in link. But **the closed-testing opt-in URL is
safe to publish**, because it only works for addresses already on the tester list. Anyone else who
opens it is refused.
So: the confirmation screen after a successful signup shows the opt-in link and says plainly *"open
this with the same Google account once you've been added — we'll announce each batch in Discord."*
Discord (D10) is the notification channel that email would otherwise be. No SMTP, no mailbox, no
deliverability problem, and no unsubscribe machinery for a list nobody is mailed from.
This is worth stating in the plan because it is the decision that makes D7 workable rather than
merely accepted.
### The page
`/beta` — what the beta is, what it needs (an Android device, a Google account, and the willingness
to stay opted in), current status, the form, and what happens next. Honest about the wait: batches
are added by hand.
### The store
SQLite at `/app/data/beta.sqlite` (bind-mounted), via `better-sqlite3`.
```
signups
id INTEGER PRIMARY KEY
email TEXT NOT NULL UNIQUE COLLATE NOCASE
created_at TEXT NOT NULL -- ISO 8601 UTC
ip_hash TEXT NOT NULL -- salted SHA-256, never the raw address
user_agent TEXT
consent_text TEXT NOT NULL -- the exact wording they agreed to, versioned
status TEXT NOT NULL -- new | exported | removed
note TEXT
```
**The raw IP is never stored** — only a salted hash, which is enough to rate-limit and not enough to
identify. The salt lives in the container environment, so rotating it destroys the linkage
deliberately. `consent_text` stores the wording itself rather than a version number, so a record can
always answer "what exactly did this person agree to" without archaeology.
Phase 5 added the second table this section describes in prose but does not draw — `attempts`
(`ip_hash`, `at`, `outcome`), which is the token bucket below, persisted as the events themselves
rather than as a counter that would need a decay schedule and a clock it trusts. It is pruned on
write, so nothing has to remember to run.
One consequence of `remove` that follows from the promise rather than from a separate choice: the
address is **overwritten**, not flagged, so afterwards the store cannot tell a removed address from
one it has never seen. Somebody who left and signs up again is an ordinary new row. Keeping a hash
so the form could say "you were removed" would mean retaining a derived identifier for the one
person who explicitly asked not to be retained.
### Abuse resistance without a third party
D9 and §6's CSP forbid external requests, so no captcha service. Instead:
- A honeypot field, hidden from real users, that bots fill.
- A minimum time-to-submit — a submission under ~2 seconds after page render is a script.
- A per-`ip_hash` token bucket persisted in SQLite: a few signups per hour, a couple of dozen per day.
- A global cap on total rows, above which the form closes and says so, so the box cannot be filled.
- Strict validation, and a duplicate submission answered idempotently ("you're already on the list")
rather than with an error that leaks whether an address is enrolled.
### Getting the list into Play
A CLI, not an HTTP route:
```bash
docker compose exec site node scripts/beta.mjs export # → /app/data/exports/<date>.csv, marks rows exported
docker compose exec site node scripts/beta.mjs export --all # everything, including already-exported
docker compose exec site node scripts/beta.mjs remove <email> # deletion request
docker compose exec site node scripts/beta.mjs stats
```
Phase 5 note: `export` writes **two** files, not one. The `.csv` is the record (id, address, date,
status, the consent wording); the `.txt` beside it is one address per line, which is what Play's
tester list actually wants pasted. Producing only the CSV would mean hand-editing it before every
paste, which is where a mistake would come from.
Deliberately not an admin page. An authenticated HTTP surface on a marketing site is a login form,
a session, a password to rotate and a thing to patch — for an operation performed by the one person
who already has shell on the host, against a file already on their disk. The CSV lands in the bind
mount and is opened locally.
`remove` exists because §9 promises deletion on request and a promise needs a mechanism.
### How phase 5 built the app and the beta
Four decisions taken before the pages were written (org lead, 2026-08-24), plus what the
repositories said when the plan above was checked against them.
**Three things this section had assumed that turned out not to hold.** §10 promised `/app/` "the 14
existing screenshots"; they exist and are the wrong fourteen (D26). §8 never said what a tester
would point the app at, and the app points at nothing by default (D27). And `/app/` can offer a
download today, because every `Android-app` release attaches a signed APK — which §8 and §10 both
omitted, having been written as though Play were the only delivery path.
**D26 — the screenshots are reserved for phase 9, and the slot ships empty.** `docs/android/screenshots/`
is a trusted-device and recovery-code smoke test from 2026-07-22: captured against a development
instance with no seeded content, before the theming work changed how every screen looks, and five
of the fourteen are two-factor prompts. Shipping them would break D4 and would show an app that no
longer looks like that. Phase 9 already stands up the review stack and seeds content for the web
screenshots, so it gains an emulator pass and the phone shots then show the same deployment on the
same day. `src/components/app/Screenshots.astro` exists now, rendering nothing, so filling it is a
data change rather than a design task. Rejected: shipping the fourteen, and pulling phase 9's rig
forward into phase 5.
**D27 — the public demo is the tester target, so the beta waits for it.** `ConnectScreen.kt` on
`Android-app` `main` is unambiguous — nothing in the app runs until a valid Runic Gateway site has
been entered and validated — so an installed app with no deployment behind it is a text field. The
alternative considered and declined was naming UOMysticmoon, which would have opened the beta to
players immediately at the cost of publishing a private shard's address on a public page. The
consequence is accepted rather than hidden: **the beta cannot start until §15's demo VM exists**,
which is the second of the two gates `/beta` states outright. It costs nothing today, because D28's
other gate — no closed test track — is open anyway.
**D28 — `/beta` handles its own POST; there is no `/api/beta-signup`.** §6 specified an endpoint,
and an endpoint cannot report a validation error without JavaScript: it answers with JSON, which
makes the form script-only, or with a redirect, which returns a person who mistyped an address to a
blank form with no explanation. Both are poor on a page whose job is conversion, and the first is
worse on a site with no analytics — a form that silently does nothing for a reader with scripts off
has no way of telling anyone it is broken. Handling the POST in the page costs one on-demand route
and buys a form that works with JavaScript disabled, renders every outcome in the real layout, and
needs no client-side code, so nothing on it argues with the CSP.
**D29 — the APK and the beta get equal billing, and the APK link is currently off.** Two panels of
the same weight: sideload today, or join the closed test for Play delivery and automatic updates.
The beta argues for itself on convenience rather than on being the only door. But the org lead
reports that the published `v0.5.0` build does not work, so `platform.json`'s `androidApk.serviceable`
is `false` and the panel renders a plain statement that the build is being replaced rather than a
link. That flag is the one value in `platform.json` with no authority to check it against, and
deliberately so — no fetch can tell whether an APK runs. `checkFacts.mjs` asserts the two assets
still exist and that `minSdk` still says what "Android 10 or newer" claims, so the link is correct
the moment a working build flips the boolean. The panel is not removed while the link is off: a page
that omitted sideloading would read, to somebody who knows the APK exists, as a page hiding it.
**Three mechanisms this phase added that the plan did not anticipate.**
- **`liveBrand()`, because a server-rendered page cannot use the boot rewrite.** §7's mechanism
rewrites files in `dist/client`; an on-demand route's HTML never was a file, so `/beta` reading
`brand` would show stock values forever. It reads the mounted `brand.json` itself, guarded by an
mtime check. That is strictly better where it applies — pasting the opt-in URL into the mount
takes effect on the **next request**, with no restart.
- **`checkLinks.mjs` learned what an on-demand route is.** `/beta` is the first on-demand *page*,
and rule 1 resolves links against the build, where it has no file. The fix is not a
`PLANNED_ROUTES` entry — that list's reverse check fires when a route has been *built*, and an
on-demand route never produces a file, so the entry could never rot out and would become the
permanent exemption the two-way check exists to prevent. Instead the routes are derived from the
source: a page exporting `prerender = false` is one. Delete `beta.astro` and the links fail again.
- **A test suite, for the first time in this repository.** The five checks of §12 all read built
output, and none of this phase's logic appears there — a honeypot can stop working entirely and
produce a build identical to one where it works. `node --test`, named file rather than directory
(`node --test test/` fails on Node 22, which is what CI runs).
---
## 9. Legal pages
Both drafted from what the code actually collects (D5), reviewed by the org lead before ship.
**Not legal advice** — accurate and specific beats generated boilerplate, and the org lead decides
whether it is sufficient.
### `/privacy`
Three separately-scoped sections, because Runic Gateway is self-hosted software and conflating them
would be wrong in both directions:
1. **This website.** No cookies. No analytics. No third-party requests of any kind. The one thing
collected is a beta signup: email address, timestamp, salted IP hash, user agent, and the consent
wording — used solely to add the address to the Google Play tester list, never sold, never mailed
to. How to have it removed. Reverse-proxy access logs and their retention.
2. **The Android app.** The point that makes this unusual and must be stated precisely: **we operate
no server the app talks to.** The app connects to an address the user enters
(`ServerPreferences` / `BaseUrlHolder`), which is run by whoever runs that community. Data the
app holds on the device — session and refresh tokens, the trusted-device token, the selected
server, the ntfy push registration — and what leaves it, and to whom. Google Play itself collects
its own data as the distributor; that is Google's policy, not ours.
3. **Self-hosted deployments.** A Runic Gateway deployment collects account data, IP addresses for
bot scoring and IP bans, session records and audit logs. **The operator of that deployment is the
data controller for it, not us.** This section exists so an operator understands the
responsibility they take on, and so no player mistakes this policy for the one governing their
community's site.
The Play Data Safety declaration is filled from section 2, and section 2 is written knowing that is
what it is for.
### `/terms`
Short and honest: the software is GPL-3.0-or-later and the licence governs its use; this site is
informational and warranty-free; the beta is a beta and may break or end; acceptable use of the
signup form; and how to get in touch. It does not attempt to govern anyone's self-hosted deployment,
because it cannot.
Both pages are linked from the footer on every page, and `/privacy` is the URL given to Play.
### How phase 6 built the legal pages
Four decisions taken before either page was written (org lead, 2026-08-24).
**D30 — One hop in front of the site, and the page says so.** §9 requires `/privacy` to state the
access logs and their retention, which needed a fact rather than a guess. The domain's DNS is on
Cloudflare but the records are **DNS-only**: no edge provider terminates the connection, so the
reverse proxy on the org lead's own host keeps the only access log there is — IP, path, user agent,
timestamp — read when something is broken or being attacked, rotated on the proxy's own schedule.
The page describes it qualitatively rather than quoting a retention number, because the number
belongs to the proxy's configuration and a policy that states one the deployment does not enforce is
worse than one that does not. **If the record is ever proxied, this section is wrong and has to be
rewritten** — an edge provider that terminates TLS is a processor, and D9's "no third-party
requests" would still be true of the browser while ceasing to be the whole story.
**D31 — Eighteen or older.** Play asks, and the answer decides whether consent alone is a lawful
basis in the EEA. Eighteen was chosen over thirteen (Google's own account minimum, but below the
children's-consent threshold in several EEA states, so a 1315 year old's consent would need a
parent's — which this form cannot obtain) and over sixteen (sufficient, but no simpler to state).
The number lives in `src/data/legal.mjs` because four surfaces render it: `/terms`, `/privacy`, the
eligibility list on `/beta`, and the consent sentence itself. **Nothing verifies it and no surface
implies otherwise** — the pages say in as many words that ticking the box is the whole of it, which
is both accurate and the only claim the code supports.
Adding the clause changed `CONSENT_TEXT`, which is stored per row rather than versioned — so rows
written from now on carry the new sentence and older ones keep theirs. `CONSENT_VERSION` gained a
suffix rather than a new date, because the change landed on the day the original wording was
written and two different sentences must not share the label an operator groups a CSV by.
**D32 — No governing-law clause.** Nothing of value is contracted for on this site: it sells
nothing, the software is free under a licence that carries its own terms, and the beta is a list of
addresses people asked to be on. A jurisdiction clause here would be decoration, and §9's standard
for these pages is that accurate and specific beats boilerplate. It stays available: adding one
later is a clause, not a rewrite.
**D33 — The Play Data Safety notes are a generated repository document.** §9 says the declaration is
"filled from section 2, and section 2 is written knowing that is what it is for" — so the two are
one array, `src/data/collection.mjs`, rendered by `/privacy` as prose and by
`scripts/playDataSafety.mjs` as the console's own questions into a committed
`PLAY_DATA_SAFETY.md`. `--check` regenerates and fails if the committed copy differs, and CI runs
it, so a hand edit is a red build that names the data file to edit instead. The document is
operator-facing rather than published: it is a form's worth of console vocabulary no visitor is
looking for, and `/privacy` already says the same things in prose.
Two properties of that file are worth keeping. **It does not pretend to know Play's current
definitions** — there is no API to read them from and the requirements have changed more than once
(the same reason `playPolicy` carries a `verifiedOn` date), so it holds the facts arranged as the
console arranges its questions, with the answer each fact supports and why; a person reads the
console's wording against them. And **a test asserts that every mapped row answers "not collected,
not shared"**, failing with the reason rather than a diff: "we operate no server the app talks to"
is the premise of the whole section, and a telemetry endpoint added later must not be able to
produce a row that quietly contradicts the lede three inches above it.
**What phase 6 also closed.** `/privacy/` and `/terms/` were the last two entries in
`checkLinks.mjs`'s `PLANNED_ROUTES`; building them emptied the list, and its reverse check is what
forced the deletion. The list itself stays, because §10's documentation routes land in phases 7 and 8
under the same convention.
---
## 10. Information architecture
Organised by what a reader is trying to do. A reader should never need to know that `link`,
`servuo-plugins` and `installer` are three repositories in order to connect a game server.
### Marketing pages
| Route | Purpose |
|---|---|
| `/` | Hero, the data path, grouped capabilities, the self-hosted argument, get-started CTA. **Reserves a "See it running" slot** for the demo (§15) — laid out now, hidden until it exists |
| `/features/` | Grouped capability presentation, explicit about what is core and what a module supplies |
| `/architecture/` | The system explained visually, for a technical evaluator deciding whether to run it |
| `/modules/` | What a module is, `module-uo` as the worked example, writing your own, the Integration Kit (draft-badged per D8) |
| `/integrations/` | Discord, mobile + ntfy push, SSO — with an explicit "not built" list |
| `/app/` | The Android app: what it does, the signed-APK download beside the beta CTA, and a screenshot slot **phase 9 fills** (D26 — the 14 existing screenshots are the wrong fourteen) |
| `/beta/` | The closed-beta signup (§8). The one page that handles its own POST (D28) |
| `/community/` | Discord (`discord.gg/t2Jav8yT4g`) as the front door, the Gitea org for code and contributions, the `brand.json` contact address for vulnerabilities (D13) — the split in §14 N3 |
| `/privacy/`, `/terms/` | §9 |
**Feature grouping**, using project terminology:
- **Community** — Teams, Team forums, notifications, wiki, news and newsletter, player self-service
- **Game intelligence** *(module-supplied; `module-uo` today)* — shard status, economy, character
sheets, points and loyalty boards, player-vendor marketplace, houses and IDOCs, spawn atlas,
champion boards, guilds, city governors
- **Administration** — roles, moderation and appeals, content reports, audit log, bot scoring and IP
bans, module management, the shard connection
- **Integration** — modules, the sidecar bridge, Discord (slash commands, notifications, voice),
mobile and push, SSO
- **Infrastructure** — self-hosted, Docker, prebuilt pull-only images, branding as data, OpenAPI
Guilds and city governors were added to Game intelligence in phase 3: `module-uo` declares them as
capabilities and the site was omitting two of the eight. That correction is now mechanical rather
than editorial — see D18.
**Community is core machinery, but two of its six need a module to fill them.** Teams and Team
forums are marked as such (D24). Core owns every part of the Team machinery and cannot create a
Team: they arrive from the installed module, so on a deployment with no module the feature is
present and permanently empty. The group's summary says so; `/features/` says why.
### How phase 3 built the homepage
Three decisions taken before the page was written (org lead, 2026-08-20).
**D17 — the data path is drawn generically, and captioned specifically.** The diagram's nodes read
"your game server", "sidecar", "Runic Gateway", "browser and app", because a reader should not have
to know this org's repository layout to understand the picture, and because the tagline promises a
platform. It does not hide what ships: the sub-labels and the caption name ServUO and uo-link
outright, since there is exactly one implementation of the shape today and §1 says the technical
truth wins. Rejected: naming the real components in the nodes (reads as a UO product), and omitting
UO entirely (advertises a generality one module proves).
**D18 — all five groups on the homepage, named only.** Not three with a link out: Integration and
Infrastructure carry the module and self-hosted arguments, which are the differentiators, and hiding
them until phase 4 would have made the front page look smaller than the product. The per-capability
argument stays `/features/`'s job so there is one copy of it.
The list is **data with a check behind it** (`src/data/capabilities.mjs`). Every Game-intelligence
item names the `module-uo` capability slug it comes from, and the build fails if the page and
`platform.json` disagree in either direction. Closing that loop needed a fifteenth fact in
`checkFacts.mjs`: §12 listed the capability list as an externally-sourced fact and nothing re-read
it, so the whole chain rested on someone remembering. Manifest → `platform.json` → page is now
checked end to end.
**D19 — the hero leads with the emblem.** Chosen over a type-only hero: the mark is already the
site logo, the Android launcher icon and the Play listing, and showing it large is what makes the
three read as one product (D11). It costs what D16 already accepted — raster art a mounted
`theme.css` cannot recolour — but every size is derived from whichever `logo.png` is in force
(D14), so the hero, the header, the tab icon and the installed icon still change together from one
file.
**A convention, not a decision:** the homepage links the final routes — `/features/`,
`/modules/`, `/integrations/` — which phases 4 to 6 have not written yet. The header and footer
already did this from phase 1. Nothing is deployed until phase 12, so no visitor meets a 404, and
nothing has to be rewritten later. Links *into the documentation* are the exception: they point at
`/docs/`, because phases 7 and 8 own those slugs and a guessed one would be a stale URL nothing
checks.
### How phase 4 built the marketing pages
Six decisions taken before coding (org lead, 2026-08-20), plus two scope items the phase table had
never assigned to anyone.
**D20 — `/features/` is the homepage's list with a `detail` line, not a second list.** Every
capability in `src/data/capabilities.mjs` gained a sentence or two of argument; `/` renders the
label, `/features/` renders the label and the detail. Rejected: slicing the page by reader
(players / staff / operators / builders), which reads better but makes the same capability appear
twice and breaks the one-to-one mapping the coverage check depends on; and deep-diving only the
differentiators, which would have left the page looking smaller than the homepage promised.
`assertDetailCoverage()` fails the build on a capability with no detail — the homepage would still
look right, and `/features/` would render a heading with nothing under it.
**D21 — `/architecture/` draws reasons, not reference.** Three new inline SVGs, each drawing one
boundary: two hosts and two installs, the public/staff allowlist, and the core/module seam. It
carries no endpoint tables, no configuration keys, no schema and no event catalog — phase 8 owns
those, they are canonical in `docs/`, and a second copy here is a copy that goes stale (§1).
Rejected: reusing the homepage's data-path diagram larger (a visitor arriving from `/` meets the
same picture twice), and adding a component/version table (starts becoming the Reference section).
The vocabulary the four diagrams now share moved to `src/styles/diagram.css`.
**D22 — the deliberate absences are one data file.** `src/data/notBuilt.mjs`, each entry tagged with
the pages that render it, because the homepage already promises a reader they will find the list on
both `/features/` and `/integrations/` and two hand-written copies is how the inconvenient half
stops appearing on one of them. Every entry carries a `resolvedBy`: an absence with an exit
condition is a position, an absence without one is a hole. That generalises what D8 already required
of the Integration Kit's draft badge.
**D23 — phase 4 absorbs `/community/` and `checkLinks.mjs`.** Neither had a phase. §10 specifies the
page and §14 N3 specifies its contents, and the header and footer have linked it since phase 1 — a
page the site pointed at that no phase built. `checkLinks.mjs` is specified in §12 and phase 4 is
what makes it load-bearing: it roughly quadrupled the internal link count and added the first
outbound links into the repositories.
**D24 — `needsModule`, because "core" and "module-supplied" were not enough.** Writing the
`/features/` detail for Teams exposed a claim phase 3 had shipped: the Community group said
"everything here works on a deployment with no game module installed at all", and that is false.
`teams.module_id` is `NOT NULL` on `website` `main`, there is no create route anywhere under
`/api/v1/admin/teams`, and sync is gated on `teamProvider.providerModuleId()`. Core owns the whole
Team machinery — tables, roster resolver, forums, notification streams, Discord bridge, voice,
activity feed, `/admin/teams` — and deliberately cannot *originate* a Team, because core does not
own the word for one. On a bare core the feature is present, correct and permanently empty. Teams
and Team forums are marked; the group summary was requalified; the homepage changed by one sentence
and kept D18's five named groups. Rejected: a sixth group for Teams (says it loudest, costs the
five-group grid phase 3 tuned), and fixing only the wording (leaves the distinction one sentence
deep and unguarded).
**D25 — the demo affordance on `/features/` is a per-capability deep link.** `brand.json` had
promised one since phase 2 without defining it. Capabilities with a stable public route carry a link
appended to the mounted `demoUrl`; the rest carry nothing, and that asymmetry is honest — a
character sheet is reachable only by the account it belongs to, and a Team forum lives behind an id
no static page can know. Paths are read from the real route tables on `main`, never guessed, which
also means they are the *module's* routes: a deployment running a different module deep-links
somewhere else.
That needed the branding pipeline extended, because the phase-3 slot could not express it. The slot
is a literal swap of a whole URL, so it can only ever put the demo's root in an `href`, and
reversing it would not even find a deep link — whose `href` is the root plus a path, matching no
literal the script knows. `applyBrand.mjs` gained a second pass that **recomputes** all three
attributes from the immutable `data-demo-path`, making it idempotent and exactly reversible, and
`checkBrand.mjs` gained a guard that lifts the pattern out of `applyBrand.mjs` and runs it against
the stock markup, so the two cannot drift. Both directions were proved against a real mount.
**One thing the checks caught about each other.** A scoped `:has([data-demo-url=''])` rule, added to
hide the wrapper around a hidden demo link, made `checkBrand.mjs` fail: it cannot tell a CSS selector
from an attribute, and it should not have to. The right fix was to delete the wrapper and let the
link be the flex item, so the existing hide rule takes the margin with it — a case where the check
being blunt pointed at simpler markup rather than at a needed exemption.
**And one thing no check caught.** `[data-demo-url=''] { display: none }` is specificity 0,1,0, and
so is the scoped class Astro puts on the same element — so a component that sets `display` wins on
source order, because component styles are emitted after `global.css`. `/features/`'s `.demo-link`
set `display: inline-flex` for its arrow, and twelve links to a demo that does not exist rendered on
the page, each pointing at `href=""` — which a browser resolves to the page it is already on.
`checkBrand.mjs` was green throughout: the attributes were perfect and the defect was three files
away, in the cascade. It was found by looking at the rendered page at 390px, which is not a
mechanism, and it is the argument for keeping the live browser pass in every phase. The rule is now
`!important` and says why in the stylesheet: while there is no demo these elements do not render,
and no component may overrule that by accident.
### Documentation
```
Getting started What is Runic Gateway? · Requirements · Install the site ·
First run · Install a game module · Connect a game server ·
Verify the whole stack
Administration Configuration · Branding and theming · Navigation and pages ·
Content · Users and roles · Authentication · Teams · Moderation ·
Notifications and email · Managing modules ·
The shard connection · Maintenance and upgrades · Troubleshooting
Modules The module system · Installing modules · Module lifecycle ·
The module manifest · The module API · Building a module ·
The Integration Kit · Testing and release
Architecture System architecture · The bridge · Authentication architecture ·
Teams architecture · Protocol versions
Reference Environment variables · Installer CLI · sidecar.toml ·
Bridge.cfg · HTTP API · Event catalog · Canonical documents
```
Roughly 38 pages — 37 planned, plus the Content page D37 added in phase 7. Every Reference page is a **navigable summary plus a link to the canonical
document** — never a re-specification, per §1.
### The installation path
The repos treat these as separate deployments; this site is the first place they appear as one
sequence. Worth stating plainly, because it is a real trap: **a "Runic Gateway install" is two
independent installs.** The installer binary sets up the *shard side only* and never contacts the
website; the website is a separate Docker deployment.
1. Requirements
2. Install the site — Docker Compose, pull-only
3. First run — first admin, maintenance → live
4. Install a game module — admin panel, `MODULES` env, or by hand
5. Connect a game server — the installer binary on the shard host (ServUO-specific today)
6. Paste the four values into **Shard (uo-link)**, `/admin/uo/link`**protocol 4**, per §2.
(Not `/admin/shard`: the screen belongs to the module now, and the installer still prints the
old path — see "How phase 7 built the documentation journey" below)
7. Verify — `[bridge status` in game, `/health` reporting `plugin_connected: true`, then `doctor`
8. Configure authentication and integrations
Each step states what the operator should expect to see, and links the failure modes to
Troubleshooting.
### How phase 7 built the documentation journey
Four decisions taken before a page was written (org lead, 2026-08-24), and three things the live
site disproved while it was being written.
**D34 — one PR for all twenty pages.** Twenty, not nineteen: see D37. The alternative on the table
was splitting Getting started from Administration so the installation path could land first; the
org lead kept the phase whole, as every phase before it has been.
**D35 — the install page is self-contained.** `/docs/getting-started/install-the-site/` prints a
complete Compose file and a complete `.env` that an operator copies without going to another
repository first. §1 argues at length against exactly this — it is a second copy of somebody else's
file, free to rot — so the copy is not trusted, it is checked. `src/data/quickstart.mjs` holds both
files and the page renders them; `scripts/checkQuickstart.mjs` re-reads `website`'s own
`docker-compose.yml` and `.env.example` from `main` over the Gitea API and fails the build on any
disagreement, in **both** directions:
- every value the quickstart states must match upstream's;
- every service and variable upstream has must be **either included or listed as deliberately
omitted, with a reason**, so a new variable in `.env.example` turns this repo red until someone
decides whether a first install needs it;
- and an entry in either omission list that upstream no longer has fails too, so the lists cannot
rot into permanent exemptions.
Same mechanism and same intent as `checkFacts.mjs`. It caught two stale entries on its first run —
`TOTP_ISSUER` and `MODULES`, which are commented *suggestions* upstream rather than keys — which is
the check earning its place before the page had shipped.
**D36 — every Administration screen was walked before it was described.** Not read from source:
opened, in a browser, on a real deployment. The rig was the quickstart itself — the exact two files
from D35, against the published image — so one run proved the install page and produced the
screenshots' worth of detail the admin pages needed. Three of the four defects below came from that
walk, and no check could have found any of them.
**D37 — a thirteenth Administration page.** §10's planned twelve named no home for Posts, Pages,
Wiki, Activity, Invites, the Hero editor or Web Bot Activity, all of which are real admin nav rows.
Rather than mirror the panel one page per row — which would organise the docs by the app's menu,
against this section's own principle — content authoring became one page, **Content**, and the
other four folded into the page that already owned their subject: Invites into Users and roles, the
Hero editor into Branding and theming, Web Bot Activity into Authentication.
**What the live deployment disproved.**
- **The documented Compose deploy does not boot.** `SECRET_ENC_KEY` is required in production —
`utils/secretBox.js` throws at require time, so the container crash-loops before it listens — and
it is **missing from website's root `.env.example`**, the file Compose actually reads. It is
present in `server/.env.example`, which is the file local development copies, which is why this
has never bitten anyone in dev. The quickstart carries it, declared as an upstream omission so the
check fails the day it is fixed. **Fixed in website#163** (merged 2026-08-24), which also adds
`BOT_INTERNAL_KEY` to the README's "set at least" list — required in production even on a
deployment running no bot. The declaration did exactly what it was built to do: this repo went red
on the next run, and the entry is deleted here.
- **The installer points operators at a screen that no longer exists.** It prints
`<site>/admin/shard`, and INSTALL.md §5 repeats it. Since the module-system cutover a module owns
one path segment, and the screen is **`/admin/uo/link`**, labelled *Shard (uo-link)*. The old path
does not even 404 — the SPA sends the operator to the dashboard, so the link looks like it worked
and the four values have nowhere to go. **Fixed in installer#22** (the path is a named constant and
both handoff tests assert it) **and docs#174**, both merged 2026-08-24, and shipped in installer
**v0.1.1**. Getting there found a fourth defect, in `installer`'s release pipeline: the run for the
fix built every artifact and pushed tag `v0.1.1`, then took a `500` from `POST /releases` one
second later, leaving an orphan tag and no binaries. Re-running the workflow published it — the
failure was a race with the tag push, not a structural one — so the note here names v0.1.0 as the
version that prints the old path rather than describing the installer as currently wrong.
- **The admin "Restart the server" button opens a `window.confirm`.** Its text is the honest
warning that a deployment with no supervisor does not come back — which is exactly why
`restart: unless-stopped` is called out as load-bearing on the install page rather than left as
boilerplate.
**And the fourth defect, the one only a look found — three phases running.** The `.env` block's
prose says *every highlighted line must be changed*, and `mark` given the variable **names**
highlighted the names alone, leaving the values a reader has to replace unmarked. The build passed,
every check passed, and the page was quietly wrong about its own highlighting. Marking the whole
`KEY=value` string fixed it. See phase 4 (cascade), phase 5 (literal backticks)
and phase 6 (the card void) for the same lesson.
---
## 11. Visual direction
**"Modern infrastructure software with an arcane identity."** Dark-first. Marketing pages are
single-theme by design; the docs honour the reader's light/dark preference.
**The mark is the existing emblem** (D11). `runic-emblem.png` — a gold-and-ruby ring around a
glowing cyan portal — is already the site logo and the Android launcher icon, so adopting it makes
the three surfaces one product. Revision 1 argued for replacing it; the org lead likes it, and a
mark already carrying recognition beats a better-drawn one that carries none.
Work needed on it, none of which is a redesign:
- Web derivatives from the 1024px source: it is a **1.4 MB PNG**, far too heavy for a header. WebP
and AVIF at header, hero and OG sizes, plus a real multi-resolution `favicon.ico` and the 192/512
PWA icons.
- A horizontal lockup — emblem beside "Runic Gateway" set in Cinzel — for the header and OG card.
- All of it lands in `/app/brand-default` (§7), so the org lead can replace any of it with an
updated logo by copying a file.
**Palette, revised.** Revision 1 proposed a muted violet secondary. That was invented; the emblem
already has a palette and it is better. The site descends from the product's tokens
(`website/client/src/styles/theme.css`) and takes its accents from the mark:
- Near-black ground and panels — the product's `--bg` family, unchanged.
- The product's steel-blue `#7f99bd` for interface and links, unchanged.
- **Gold and cyan from the emblem** as the accent pair — gold for emphasis and rules, the portal's
cyan for the glow behind the diagrams and the live-state signal. Both derived from the artwork by
sampling, not guessed, and both held to WCAG AA against the ground.
- The product's existing live/maintenance signal colours (`--mode-live`, `--mode-maint`) reused
verbatim, so a status pill means the same thing on both sites.
**Type:** Cinzel — already the project's display face, and already in the Android app's `res/font`
for the wordmark and hero only; a modern variable sans for everything else. Self-hosted, so there
are no external font requests and §6's CSP needs no exception.
**Motif:** hand-drawn SVG geometry — a gateway glyph derived from the emblem's concentric rings,
thin luminous topology lines, layered panels. Subtle, structural, used where it explains something.
**No AI-generated rune artwork anywhere.**
**Maturity signals** (D8) are typographically quiet: a small "draft" chip on Integration Kit pages,
version chips wherever a component is named. No apologetic tone anywhere. The Integration Kit chip
has a defined removal condition — a second module built successfully against the kit — recorded here
so a future reader knows when to take it down.
---
## 12. Accuracy machinery
The site quotes versions, protocol numbers and capability lists. Given §1's process rule, that needs
a mechanism rather than diligence:
- **`src/data/platform.json`** — one file holding every externally-sourced fact: protocol version,
Module API version, current bundle tag, component versions, `module-uo`'s capability list. Every
page reads from it. **No version number is ever hardcoded in prose.**
- **`scripts/checkFacts.mjs`** — fetches the authority for each fact and **fails the build on any
disagreement**:
| Fact | Authority |
|---|---|
| Protocol version | `link` `main:sidecar/src/main.rs``PROTOCOL_VERSION` |
| Overlay protocol | `servuo-plugins` `main:overlay.toml``protocol` |
| Module API | `website` `main:server/src/modules/version.js``MODULE_API_VERSION` |
| Module capability list | `Module-uo` `main:module.json``capabilities` (added in phase 3) |
| Bundle + component pins | `installer` branch `bundles`, **root** `current.json` |
| Release versions | Gitea releases API per repo |
Same mechanism and the same intent as the Integration Kit's `checkCoreApi.js`: when the platform
moves, this repo goes red so someone updates the site. That failure is the feature.
It carries one rule that is not a version: **no email address may appear anywhere in the source
except `brand-default/brand.json`.** D13 publishes a personal address that is meant to be
replaceable by a file copy, and that promise survives exactly as long as nobody types the address
into a paragraph. Same argument as `checkTokens.mjs` and colour literals — the check is the
mechanism, diligence is not.
- **`scripts/checkLinks.mjs`** — every internal link resolves; every outbound link into a
`RunicGateway` repo points at a branch path, not a commit permalink. **Built in phase 4** (D23),
and it reads `dist/client` rather than `src/`: half the links these pages carry are assembled from
data files and template literals, and a source scan sees an expression rather than a URL. It runs
after the build for that reason, in `verify` and in CI. It fetches nothing — the outbound rule is
about the shape of a URL, and a check that fails when someone else's host is slow is a check
people learn to ignore.
It carries one exemption list, `PLANNED_ROUTES`, because §10's convention is that the header,
footer and homepage link the *final* routes rather than growing links phase by phase. That is safe
only because the list is checked in both directions: a link to a route that is neither built nor
listed fails, **and an entry whose route has since been built also fails**, so the list cannot rot
into a permanent exemption once the page arrives.
- **`scripts/checkBrand.mjs`** also guards the demo slot and, since phase 4, the per-capability deep
links (D25) — lifting the pattern out of `applyBrand.mjs` and running it against the stock markup,
so a template and a script that share no code cannot drift apart. Both are invisible in a stock
build, which is exactly why they need a check rather than a look.
- **`scripts/checkQuickstart.mjs`** — added in phase 7 for D35. The install page prints a Compose
file and an `.env` verbatim, which is the one place this site knowingly copies another repo's
file; this re-reads `website` `main:docker-compose.yml` and `main:.env.example` and fails on any
disagreement. Two-directional, like `PLANNED_ROUTES`: a value that drifts fails, **and** a service
or variable that appears upstream fails until it is either included or recorded as deliberately
omitted with a reason. Its own first run found two stale entries.
- **`scripts/checkTokens.mjs`** — no colour literal outside the token file (§7).
- `astro check` plus a production build, in CI on every PR.
---
## 13. Build phases
| Phase | Deliverable |
|---|---|
| **0** | The documentation fixes in §4 — all ten conflicts, across `docs`, `.profile` and `Module-uo` |
| **1** | Foundation: Astro + Node adapter scaffold, the token file, typography, layout shell, header/footer, docs theming and sidebar, `platform.json` + `checkFacts.mjs` + `checkTokens.mjs` |
| **2** | Branding pipeline (§7): `/brand/*` resolution, `brand-default` contents, the emblem's web derivatives and lockup, `brand.json` wiring |
| **3** | Homepage: hero, the data-path diagram as inline SVG, grouped capability sections, CTA, the reserved demo slot |
| **4** | Marketing: `/features/`, `/architecture/`, `/modules/`, `/integrations/`, **and `/community/`** — plus `checkLinks.mjs`, the capability `detail` lines, `notBuilt.mjs` and the demo deep links. See D20D25 |
| **5** | The app and the beta: `/app/`, `/beta/`, the signup handler, the SQLite store, rate limiting, the export CLI (§8). **Also the repository's first `node --test` suite**, and phase 9 inherits an emulator pass (D26) |
| **6** | Legal: `/privacy/`, `/terms/`, footer links, and the Play Data Safety notes (§9) |
| **7** | Docs — the journey: Getting started (7) + Administration (**13**, per D37) — twenty pages in one PR (D34), with the install page self-contained and drift-checked (D35) and every admin screen walked before it was described (D36). **The installation path is the priority of the whole project** |
| **8** | Docs — builder and reference: Modules (8) + Architecture (5) + Reference (7) |
| **9** | Screenshots (D4): stand up the local review stack, seed presentable content, capture the admin panel, Teams, forums, marketplace, spawn atlas and shard console; build the screenshot components. **Plus an emulator pass against the same seeded stack** to fill `/app/`'s reserved slot (D26) |
| **10** | Polish: responsive, accessibility, SEO/OpenGraph/sitemap/robots, full-text search, CSP headers |
| **11** | Validation: `astro check`, production build, **all six check scripts** (tokens, brand, links, facts, quickstart, data safety), mobile layout verified in a real browser, a signup walked end to end |
| **12** | Delivery: Dockerfile, `docker-compose.yml` with both bind mounts documented, Gitea Actions workflow publishing to the registry, README, CONTRIBUTING with the AI-disclosure requirement, and an operator note covering DNS, TLS and the reverse proxy (D6) |
Phases 5 and 6 are deliberately adjacent and early: the beta cannot start without `/privacy`, and
the closed test is the nearest real deadline.
---
## 14. Still needed from the org lead
None of these block starting Phase 0 or Phase 1.
**N1 — Resolved.** `runicgateway.com` is registered through **Cloudflare**, with DNS on Cloudflare.
The domain does not resolve to anything yet; the record is pointed at the host in phase 12.
**N2 — Settled by D13, and no longer blocking anything.** As of 2026-08-19 no mailbox exists at the
domain and the org lead chose not to wait for one. **`whitlocktech@gmail.com` is the published
contact**, and `docs/SECURITY.md` keeps the address it already carries.
The site is built so this is reversible for the cost of a file edit. The address is a `brand.json`
field (§7), read by `/privacy`, `/terms`, `/community` and the OG/structured metadata; **it appears
in no page's prose and in no committed markdown**, and `checkFacts.mjs` (§12) fails the build if a
literal `@` address is found outside `brand-default/brand.json`. Changing the published contact is
then the same operation as changing the logo — copy a file, restart the container.
The recommendation stands for whenever it is wanted: **Cloudflare Email Routing**, now that N1
confirms the domain is on Cloudflare. Free, unlimited, receive-only, and it adds its own MX and SPF
records automatically. Three addresses, all forwarding to the existing inbox:
| Address | Would replace the Gmail in |
|---|---|
| `hello@runicgateway.com` | The Play store listing's contact email; `/community` |
| `privacy@runicgateway.com` | `/privacy` — deletion and data requests (§9) |
| `security@runicgateway.com` | `docs/SECURITY.md` (a one-line `docs` PR, whenever) |
Pair it with Gmail's "Send mail as" to reply from the addresses rather than the underlying inbox.
Routing is receive-only, which is sufficient — the site sends nothing (D7). If double opt-in is ever
wanted later, Resend or Brevo's free tier plus DKIM/DMARC would cover sending.
**One consequence to accept knowingly:** a Play store listing's contact email is public, and so is
this site, so the address will be scraped. That is the trade the org lead took against delaying the
beta; the mitigation is that switching later costs one file.
**N3 — Gitea registration.** Open registration previously drew a flood of spam accounts, so it is
disabled, and "file an issue" is therefore a wall for anyone outside the org. Reopening it safely on
**Gitea 1.24.7** is a config change, not a gamble:
```ini
[service]
DISABLE_REGISTRATION = false
REGISTER_MANUAL_CONFIRM = true ; an admin approves each account before it can sign in
REGISTER_EMAIL_CONFIRM = false ; manual confirm is ignored unless this is off
ENABLE_CAPTCHA = true
CAPTCHA_TYPE = cfturnstile
CF_TURNSTILE_SITEKEY = <from Cloudflare — free, and the domain is already there>
CF_TURNSTILE_SECRET = <ditto>
DEFAULT_ALLOW_CREATE_ORGANIZATION = false
DEFAULT_KEEP_EMAIL_PRIVATE = true
[repository]
MAX_CREATION_LIMIT = 0 ; -1 is unlimited; 0 means no user may create a repository
```
- **`REGISTER_MANUAL_CONFIRM` is the control that actually stops the flood.** Registrations queue in
Admin → User Accounts and cannot sign in, comment or open issues until approved, so a spam run
produces a list to delete rather than content to clean up.
- **`MAX_CREATION_LIMIT = 0` is defence in depth**, and is what "users cannot create repos by
default" means concretely. Raise it per-account in Admin → User Accounts for anyone who should be
able to. **Set the org lead's own account to `-1` explicitly before flipping the global**, rather
than assuming admin accounts bypass it.
- **Turnstile** is free and the domain is already on Cloudflare, so it costs one form.
- **Caveat worth weighing:** with no mailer configured on Gitea, nobody is notified when an account
is pending, and the applicant is not told when they are approved. That makes Gitea a slow channel
for someone who just wants to report a bug.
**Consequently, `/community` should describe an honest split rather than one channel:** Discord
(`discord.gg/t2Jav8yT4g`) is the front door for questions, bug reports and the beta announcements —
instant and unauthenticated. Gitea is where the code lives, is publicly readable without an account,
and takes issues and pull requests from contributors willing to request one. The `brand.json`
contact address (D13) is the private channel for vulnerabilities. That page is written the same way
whether or not N3 is actioned; only one sentence changes.
---
## 15. Planned, not in scope: the demo instance
D12. Recorded now so the IA reserves room for it and nothing has to be restructured later.
**Shape:** a VM on the org lead's Proxmox host running the full stack — website, `module-uo`, the
uo-link sidecar and a real ServUO shard — so an evaluator clicks one link and sees the actual
product with live game data flowing through the bridge, which no screenshot can convey.
**Constraints that make it safe to run:**
- **Automatic reset roughly hourly** from a golden snapshot, so nothing an anonymous visitor does
outlasts the hour. Reverting the VM is simpler and more complete than any application-level reset.
- **Restricted settings** — the demo account reaches most of the admin panel, but not the surfaces
that would let a visitor break out or reach the network: SSO client secrets, the uo-link token and
base URL, mail, the module installer, and anything shelling out. The specific allow-list is
designed when it is built, against the admin route inventory.
- Seeded content shared with phase 9's screenshots, so the demo and the site's imagery agree.
- Isolated network segment; no path from the demo VM to anything else on the host.
- Its own subdomain, its own TLS, a `noindex` header, and a banner stating it resets hourly.
**Site-side preparation done now, for free:** the homepage lays out a "See it running" slot and
`/features/` a per-capability "try it" affordance, both rendered only when
`brand.json` carries a demo URL. When the VM exists, the site gains a working demo by way of one
line in a bind-mounted file — no rebuild, consistent with §7.
---
[api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md
[system]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_SYSTEM.md
[int]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md
[v3]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v3.md
[v4]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v4.md
[teams]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/TEAMS.md
[backend]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/BACKEND_DESIGN.md
[install]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md