# 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:` answered confidently from a months-old blob. Local `main` was 1–3 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/` 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.2** (2026-08-25) | 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, 58–63, 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, 522–523, 657–658, 705–706, 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 1–5 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 6–10 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.5–0.9 are grouped where they touch one repo, so the real PR count is smaller than the row count; `docs` PRs 0.5–0.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.1–0.3), `docs`#173 (0.5–0.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 **fifty-three**: | # | Where | What it settled | |---|---|---| | D14–D16 | §7, "How phase 2 actually built it" | The branding pipeline: one raster in, brand text applied at boot, the mark is the real emblem | | D17–D19 | §10, "How phase 3 built the homepage" | The data-path diagram, all five groups on the homepage, the emblem-led hero | | D20–D25 | §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 | | D26–D29 | §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 | | D30–D33 | §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 | | D34–D37 | §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 | | D38–D41 | §10, "How phase 8 built the builder and reference docs" | One PR for all twenty pages again, Reference enumerates names and checks every one of them, the docs section links to the drawn diagrams rather than importing them, `plannedSidebar` becomes a checked invariant | | D42–D46 | §10, "How phase 9 took the screenshots" | The full rig behind the imagery, a neutral demo brand, the captures beside the claims, a committed and checked capture pipeline, the world dressed in the plugin repo's scaffolding | | D47–D50 | §6, "How phase 10 polished it" | Search reaches the marketing pages, the CSP is a real response header from the container, `robots.txt` allows everything and names the sitemap, two blocks of structured data and no more | | D51–D53 | §6, "How phase 11 validated it" | The chrome and the head follow the brand mount while the consent sentence does not, the documentation half gets phase 10's skip-link fix, and no twelfth check | --- ## 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. ### How phase 10 polished it Four decisions, D47–D50, taken 2026-08-25. Three of them were straightforward; the fourth turned into the phase's real work, because the thing that was supposed to be a configuration flag was broken in a dependency and broken *silently*. **D47 — search reaches the marketing pages, and the marketing header gets a box.** The documentation had search from phase 1: Starlight builds a Pagefind index at the end of every build. The marketing pages were outside it twice over — not indexed, so a reader searching "Teams" in the docs found the architecture page and never the feature page; and with no box, so a reader who arrived on the homepage had a four-item nav and no way to ask a question. `Base.astro` now marks its `
` as a Pagefind body, which puts all ten in the index the docs already query, and a `Search.astro` in the header opens the same index in a ``. Three things about the build are worth keeping. **Nothing is fetched until the dialog is opened** — Pagefind's UI bundle is 120 kB before the index and the WASM, and these pages otherwise ship almost no JavaScript, so the button is inert markup and the first open injects the script. **`` rather than a hand-built overlay**, because the browser supplies the focus trap, the inert background, Escape-to-close and the top layer, and every one of those is something an accessibility pass would otherwise find missing. And **the index needed an explicit title**: Pagefind titles a result from the first `

`, and these pages have editorial ones — `/app/`'s is "The app for a deployment you already use", `/terms/`'s is "Short, and only about what we run". Correct on the page under an eyebrow that names the section; unscannable as four rows in a result list, which is exactly what the first walk of the finished search produced. `data-pagefind-meta` now carries the page's short name, the one already in the nav and the browser tab. **Two things about styling somebody else's widget.** Pagefind's UI takes a `resetStyles` option; setting it to `false` — on the reasoning that the site's own type and colour should show through — is wrong, because that reset is what styles Pagefind's own input and buttons. Without it they fall back to user-agent defaults, which on this ground meant black text typed into a dark field and a Clear button with an `outset` border. The palette is bound through Pagefind's custom properties instead. And the match highlight needed one extra class in the selector: the reset declares `.pagefind-ui--reset mark { all: revert }`, same specificity as a plain descendant rule and injected after our stylesheet, so it won on order and put the user-agent yellow back on every result. It also closed a note phase 2 left here. Pagefind indexes at build time, so the boot rewrite (§7, D15) reached the pages and not the search results: a site renamed through the mount would answer a search for its own name with the stock one. `applyBrand.mjs` now re-indexes after a rewrite — only when it actually rewrote something, so a stock deployment still pays nothing. **D48 — the CSP is a real response header, sent by the container.** The alternatives were a ``, which is what Astro emits by default and which silently ignores `frame-ancestors` — the one directive that stops the site being framed — and writing the headers into an operator's reverse-proxy configuration, which puts the strictest promise in §6 outside the artifact this repository builds and tests. Neither is good enough for a security boundary, so the Node adapter's `staticHeaders` is on: the build writes one policy per prerendered route into `dist/_headers.json` and the server sends it. **Three things fought this, and each is the same shape: correct build, broken page, no error.** 1. **Astro does not hash `