Revision 1 ended in five open questions. All are now answered, plus the domain and Gitea-registration facts, so the plan states decisions instead of asking. New section 5 holds the twelve decisions of record. Sections 6-9 design against them: the Astro + Node adapter runtime, branding resolved out of a bind mount against baked defaults, the Android closed-beta signup, and the legal pages the beta cannot ship without. Re-verified every version against the Gitea API and corrected three things revision 1 got wrong: the bundle manifests live at the root of the bundles branch rather than under bundles/, the website publishes no releases at all, and the Android app targets no server of ours - the user enters the address, which is load-bearing for the privacy policy. Phase 0 grows from the four operator-facing documentation fixes to all ten conflicts, plus retiring the personal Gmail that SECURITY.md publishes as the public vulnerability contact. Two design notes worth flagging. Play does not notify testers on the email-list path either, so a site that sends no mail looked blocked - but the closed-testing opt-in URL only works for addresses already on the list, so it is safe to publish on the confirmation screen, with Discord carrying the announcement. And the CSV export is a CLI against the bind mount rather than an admin page, so the site keeps no authenticated surface at all. The demo instance is recorded as planned and out of scope: a Proxmox VM running the full stack including ServUO, restricted and reset hourly. The site reserves its slot now so it costs one line in a bind-mounted file later. Co-Authored-By: Claude <noreply@anthropic.com>
727 lines
44 KiB
Markdown
727 lines
44 KiB
Markdown
# runicgateway.com — design of record
|
||
|
||
**Status:** decisions taken by the org lead on 2026-08-19 and recorded in §5. Nothing is built yet.
|
||
**Date:** 2026-08-19 (revision 2)
|
||
**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. §14 lists the three facts still outstanding — none of them block starting.
|
||
|
||
---
|
||
|
||
## 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 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/<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.0** (2026-08-07) | 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, 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` (§14, N2). Conditional on that address existing. |
|
||
|
||
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.
|
||
|
||
---
|
||
|
||
## 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 (§14, N2). |
|
||
| **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. |
|
||
|
||
---
|
||
|
||
## 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.
|
||
|
||
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 │
|
||
│ └── POST /api/beta-signup ..... writes SQLite on the bind 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.
|
||
|
||
### 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.
|
||
|
||
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.
|
||
|
||
### 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
|
||
```
|
||
|
||
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.
|
||
|
||
---
|
||
|
||
## 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.
|
||
|
||
---
|
||
|
||
## 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 14 existing screenshots, and the beta CTA |
|
||
| `/beta/` | The closed-beta signup (§8) |
|
||
| `/community/` | Discord (`discord.gg/t2Jav8yT4g`) as the front door, the Gitea org for code and contributions, `security@` for vulnerabilities — 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, player-vendor
|
||
marketplace, houses and IDOCs, character sheets, spawn atlas, champion boards, points leaderboards
|
||
- **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
|
||
|
||
### 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 ·
|
||
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 37 pages. 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 Admin → Shard — **protocol 4**, per §2
|
||
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.
|
||
|
||
---
|
||
|
||
## 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` |
|
||
| 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.
|
||
- **`scripts/checkLinks.mjs`** — every internal link resolves; every outbound link into a
|
||
`RunicGateway` repo points at a branch path, not a commit permalink.
|
||
- **`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/` |
|
||
| **5** | The app and the beta: `/app/`, `/beta/`, the signup endpoint, the SQLite store, rate limiting, the export CLI (§8) |
|
||
| **6** | Legal: `/privacy/`, `/terms/`, footer links, and the Play Data Safety notes (§9) |
|
||
| **7** | Docs — the journey: Getting started (7) + Administration (12). **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 |
|
||
| **10** | Polish: responsive, accessibility, SEO/OpenGraph/sitemap/robots, full-text search, CSP headers |
|
||
| **11** | Validation: `astro check`, production build, all four check scripts, 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 — A contact address at the domain. Recommendation: Cloudflare Email Routing**, now that N1
|
||
confirms the domain is on Cloudflare. It is free, unlimited, receive-only, and adds its own MX and
|
||
SPF records automatically. Three addresses, all forwarding to the existing inbox:
|
||
|
||
| Address | Used by |
|
||
|---|---|
|
||
| `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`, which currently publishes a personal Gmail on a public repo |
|
||
|
||
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.
|
||
|
||
Retiring the personal address from `SECURITY.md` is a one-line `docs` PR and rides along with
|
||
Phase 0.
|
||
|
||
**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. `security@` 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
|