All checks were successful
PR checks / checks (pull_request) Successful in 9m36s
PLAN.md §13 phase 10, with four decisions of record — D47-D50, taking the count
to fifty. Three were straightforward; the CSP turned into the phase's real work,
because the thing meant to be a configuration flag was broken in a dependency and
broken silently.
D47 — search reaches the marketing pages, and the header gets a box.
Base.astro marks its <main> as a Pagefind body, so all ten join the index the
docs already query, and Search.astro opens it in a <dialog>. Nothing is fetched
until the dialog is opened (the bundle is 120 kB and these pages otherwise ship
almost no JavaScript). Pagefind titles a result from the first <h1>, and these
pages have editorial ones — "The app for a deployment you already use" — so the
index is given the page's short name instead. applyBrand.mjs now re-indexes after
a rewrite, closing a note phase 2 left for this phase.
D48 — the CSP is a real response header, sent by the container. Not a <meta>,
which ignores frame-ancestors, and not advice for someone's reverse proxy, which
puts the strictest promise in §6 outside what this repo tests. Three things
fought it, all the same shape — correct build, broken page, no error:
* Astro does not hash <script is:inline>, and Starlight ships six per docs
page, so the first build with CSP on had a strict header and a dead theme
switcher. The hashes are now generated into src/config/cspHashes.mjs and
checkCsp.mjs verifies every inline block against its own page's policy.
* Expressive Code writes ~3,700 inline style ATTRIBUTES, which cannot be
hashed, hence style-src-attr 'unsafe-inline' — scoped to that directive, so
script-src is untouched.
* @astrojs/node matched a request to a policy with pathname.includes(), a
substring test: /modules/ was served /docs/modules/building-a-module's
policy and rendered with its own stylesheet refused. scripts/serve.mjs keeps
the same _headers.json and matches by equality; test/headers.test.mjs starts
the server and reads the responses, because nothing that reads dist/ can see
this.
D49 — robots.txt allows everything and names the sitemap (there was no way to
find it: no robots.txt, and D9 rules out a search console). D50 — Organization
and SoftwareApplication, no ratings and no docs-wide Article markup.
checkA11y.mjs is the eleventh check: seven structural rules over all fifty pages,
verified by breaking each in turn. The walk at 390/768/1280 found no overflow
anywhere, the CSP violations above, a 17x17 consent checkbox (WCAG 2.2 SC 2.5.8
wants 24), and a skip link that moved the scroll but not the focus.
npm run verify is green: fourteen steps, both test suites, all eleven checks.
Co-Authored-By: Claude <noreply@anthropic.com>
1513 lines
104 KiB
Markdown
1513 lines
104 KiB
Markdown
# 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 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.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**:
|
||
|
||
| # | 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 |
|
||
|
||
---
|
||
|
||
## 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
|
||
`<main>` 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 `<dialog>`.
|
||
|
||
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. **`<dialog>`
|
||
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 `<h1>`, 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
|
||
`<meta http-equiv>`, 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 `<script is:inline>`.** It hashes what it processes; an inline script is
|
||
the author's own text, which it never parses. Starlight ships six per documentation page — the
|
||
theme provider, the theme-picker sync, the mobile menu, the sidebar scroll restore. The first
|
||
build with CSP enabled produced a strict, correct header and a documentation site whose theme
|
||
switch and mobile sidebar did nothing, with the explanation only in a console. `'unsafe-inline'`
|
||
would have fixed all six and given up the single directive CSP exists to enforce, so instead the
|
||
hashes are enumerated in a generated `src/config/cspHashes.mjs` and `scripts/checkCsp.mjs`
|
||
verifies, per page, that every inline block is covered by *that page's own* policy. A Starlight
|
||
upgrade that edits one byte turns the build red; `npm run csp:hashes` re-harvests it.
|
||
|
||
2. **Expressive Code cannot be hashed at all.** Around 3,700 inline `style` **attributes** across
|
||
the documentation carry every syntax colour, and CSP hashes cover `<style>` elements, never
|
||
attributes — Astro's own documentation records Shiki as incompatible with CSP for this reason.
|
||
The policy therefore carries `style-src-attr 'unsafe-inline'`, scoped to that directive: a style
|
||
attribute cannot execute script, so `script-src` is untouched. The marketing pages emit none.
|
||
|
||
3. **`@astrojs/node` served the wrong page's policy.** Its per-request lookup is
|
||
`headersMap.find((h) => h.pathname.includes(baselessPathname))` — a substring test taking the
|
||
first match. `/modules/` was served the policy built for `/docs/modules/building-a-module`;
|
||
`/architecture/` got a docs page's; and `/`, a substring of every path in the file, got whichever
|
||
record came first, which was `/404`. Since each policy is a list of per-page hashes, the browser
|
||
refused each page's own stylesheet: `/modules/` and `/architecture/` were rendering unstyled,
|
||
and the homepage looked perfect only because it happened to share a hash with the 404 page.
|
||
`scripts/serve.mjs` — a thin wrapper `npm start` now runs instead of the adapter's entry — keeps
|
||
the same `_headers.json` and matches by equality. It is small on purpose so it can be deleted
|
||
whole when upstream is fixed, and it is where the non-CSP security headers live too.
|
||
|
||
**This is why `test/headers.test.mjs` exists.** Every other check in this repository reads
|
||
`dist/`, and every file on disk was right — the bytes on the wire were not. It starts the server
|
||
and reads the responses, and reverting the wrapper to the substring lookup fails it.
|
||
|
||
**D49 — `robots.txt` allows everything and names the sitemap.** The sitemap has covered all fifty
|
||
URLs since phase 1 (Starlight bundles `@astrojs/sitemap`) and nothing pointed at it; a crawler finds
|
||
one either from this file or from a search console, and D9's posture extends to not having an
|
||
account with anyone. Nothing is disallowed: there is no authenticated surface (§6), `/brand/*` is
|
||
derived images with no text, and `/beta/` is a page a person is meant to find. The 404 is kept out
|
||
of the *search index* instead, with `data-pagefind-ignore`, which is the right layer for it.
|
||
|
||
**D50 — two blocks of structured data, and no more.** `Organization` so the project's name resolves
|
||
to an entity rather than to whichever page ranks, and `SoftwareApplication` because what the site
|
||
describes is software someone installs. No ratings, no counts, no invented `aggregateRating` — §11's
|
||
understated honesty applies to markup a reader never sees, and inventing a rating is what gets
|
||
structured data ignored. Breadcrumb and `Article` markup on the forty documentation pages was
|
||
rejected: Starlight already renders breadcrumbs a reader can see, and it would be forty more places
|
||
for a fact to go stale. Every value is read from `brand.json` or `platform.json`, so `checkFacts.mjs`
|
||
already guards them.
|
||
|
||
It is a `<script type="application/ld+json">`, which is a data block: no browser executes it and no
|
||
CSP hash covers it. **Both `checkCsp.mjs` and `applyBrand.mjs` had to be taught that explicitly** —
|
||
the first would have demanded a hash for text that changes whenever a fact does, and the second
|
||
would have refused to rewrite the homepage at all, which is §7 failing on the page that matters
|
||
most.
|
||
|
||
**What the walk found.** Ten marketing pages and a documentation sample, at 390, 768 and 1280 in
|
||
real Chrome. No horizontal overflow at any width, on any page — the responsive work of phases 3 and
|
||
4 held, including with a search button added to the header. The CSP violations above. The consent
|
||
checkbox on `/beta` measured 17×17 against WCAG 2.2 SC 2.5.8's 24px minimum, and is now 24 — the one
|
||
control on the site a person must hit precisely, on the page a phone is most likely to arrive at.
|
||
And following the skip link moved the scroll but not the focus, because a `<main>` is not focusable;
|
||
Chrome papers over that and not every browser does, so it now carries `tabindex="-1"`.
|
||
|
||
**`checkA11y.mjs` is the eleventh check**, and the eighth in CI. Seven structural rules over every
|
||
built page, ours and Starlight's forty. Structural on purpose: a static check cannot measure
|
||
contrast on a rendered page or find a focus trap, and one that pretended to would be trusted for
|
||
things it cannot see. Its own first run reported every marketing page as having two `<main>`
|
||
landmarks — this repository comments its markup heavily, and one of those comments quotes the tag it
|
||
is explaining, so comments are stripped before anything is counted. It was then verified by breaking
|
||
each of its rules in turn.
|
||
|
||
---
|
||
|
||
## 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.
|
||
|
||
*Filled in phase 9:* six captures, from an emulator pointed at the same seeded deployment the web
|
||
screenshots came from, on the same day — see "How phase 9 took the screenshots" in §10. The
|
||
component now reads `src/data/screens.mjs` rather than a list of its own, which is what made it a
|
||
data change in the end.
|
||
|
||
**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 13–15 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 six phone captures **phase 9 filled** (D26 — the 14 existing screenshots were 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
|
||
```
|
||
|
||
**Forty pages** — thirty-nine planned, plus the Content page D37 added in phase 7. (This said "roughly 38, 37 planned" until phase 8 counted the tree: 7 + 13 + 8 + 5 + 7. `checkSidebar.mjs` now keeps the count honest.) 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.
|
||
|
||
---
|
||
|
||
### How phase 8 built the builder and reference docs
|
||
|
||
Twenty more pages — Modules (8), Architecture (5), Reference (7) — completing the tree §10
|
||
planned. Four decisions, taken by the org lead before anything was written.
|
||
|
||
**D38 — one PR for all twenty pages, again.** The alternative on the table was splitting the
|
||
prose (Modules + Architecture) from Reference, since only Reference needed new checking
|
||
machinery. Rejected for the same reason D34 was: the three sections cross-reference each
|
||
other heavily, and a split means either landing pages whose links point at nothing yet or
|
||
writing the links twice.
|
||
|
||
**D39 — Reference enumerates the NAMES, and checks every one of them.** This is the phase's
|
||
central decision, because §1 forbids re-specifying a contract and a Reference section is
|
||
exactly where that rule is most tempting to break.
|
||
|
||
The line drawn: **names are on the page, semantics are not.** Every environment variable,
|
||
config key, installer command, visibility rung and canonical document is listed, with one
|
||
terse line saying what it is *for*. Shapes, defaults that matter, interactions and every
|
||
"why" stay in the canonical document.
|
||
|
||
That is only safe because `scripts/checkReference.mjs` compares each list against the
|
||
repository that owns it — six sources, over the Gitea API, never from a working tree — as a
|
||
**set comparison in both directions**. The second direction is the one that earns its keep:
|
||
a reference page does not usually rot by describing something that vanished, it rots by
|
||
quietly not mentioning the three things added since it was written.
|
||
|
||
The alternative considered was strict summary-plus-link with nothing enumerated. It needs no
|
||
machinery and cannot rot — but a Reference section that cannot answer "what variables are
|
||
there?" without a click-through is a link farm, and the checking machinery turned out to be
|
||
one script.
|
||
|
||
Descriptions are deliberately **not** checked, and the script says so. Nothing can know
|
||
whether a one-line summary is still true; keeping them short enough to re-read is the
|
||
mitigation, not a check.
|
||
|
||
**D40 — the docs link to the drawn diagrams rather than importing them.** `/architecture/`'s
|
||
three diagrams are Astro components carrying marketing chrome and depending on
|
||
`src/styles/diagram.css`, which Starlight does not load. Reusing them inside the docs would
|
||
have coupled the two layouts for one page's benefit. The docs use text diagrams in code
|
||
blocks — which are also copy-pasteable into an issue — and link out to the drawn versions.
|
||
|
||
**D41 — `plannedSidebar` stops being a checklist and becomes a checked invariant.** It was
|
||
written in phase 1 so phases 7 and 8 had their checklist where they would be working. With
|
||
every page now written it is a second, hand-maintained copy of the live tree, which is the
|
||
exact shape §1 warns about — so `checkSidebar.mjs` asserts the two agree on groups, labels
|
||
**and order**.
|
||
|
||
Order, because the order of "Getting started" *is* the installation path, and a reordering
|
||
nobody noticed would be a worse defect than a missing page.
|
||
|
||
**What the checks found, before any of the pages shipped.**
|
||
|
||
- **`plannedSidebar` had already drifted.** Phase 7 added the Content page under D37 and
|
||
never updated the planned list. Nothing failed, because nothing read it — which is the
|
||
whole argument for D41. Reproduced by deleting the entry again and watching the new check
|
||
catch it.
|
||
- **The page count in this document was wrong**, and had been since §10 was written: it said
|
||
"roughly 38 — 37 planned", where the tree it describes is forty.
|
||
- **`module.json`'s `mounts` and the SPA's paths are different mechanisms**, which is not
|
||
stated plainly in any one place. `module-uo` declares `admin: ["/shard", "/uo-link"]` and
|
||
its screen lives at `/admin/uo/link`; API routes are deliberately *not* namespaced while
|
||
SPA routes are. That is the distinction the installer got wrong in v0.1.0, and it now has
|
||
a named home on *The module system*.
|
||
|
||
**The check was verified by breaking it, not by watching it pass.** It went green on its
|
||
first run, which is the least trustworthy possible outcome, so seven mutations were fed
|
||
through it — a stale name, an omitted name, a renamed key in each of three sources, a
|
||
canonical document that moved, and the visibility ladder **reordered with its membership
|
||
unchanged**. All seven failed the build. The ladder case is the one worth keeping: it is a
|
||
security boundary, and a set comparison alone would have passed it.
|
||
|
||
---
|
||
|
||
### How phase 9 took the screenshots
|
||
|
||
D4 said real screenshots from the review stack rather than placeholders, and left the how
|
||
open. Five decisions settled it, taken by the org lead before the rig was built.
|
||
|
||
**D42 — the full rig: a real shard, a real sidecar, a real site.** ServUO with the bridge
|
||
overlay on this machine, the Rust sidecar beside it, `website` `main` with `module-uo`
|
||
installed, and the demo database seeded on top for what a fresh shard cannot produce.
|
||
|
||
The alternatives were cheaper and both of them lie a little. Sidecar-only screenshots the
|
||
degraded state — a reachable bridge with nothing behind it. Everything-database-seeded
|
||
produces pages that look identical to the real thing and were produced by nothing: the
|
||
marketplace would be rows somebody typed. This is the one option where the marketplace rows
|
||
are player vendors the game actually holds, the atlas is parsed from the shard's own spawn
|
||
files, and "Candlewick House is now IDOC" happened.
|
||
|
||
**D43 — a neutral demo brand.** The deployment is "Runic Gateway Demo", not UOMysticmoon.
|
||
The screenshots show the platform rather than one private community, which is the same
|
||
instinct as D27's refusal to publicise a real shard — and §15's demo instance can wear this
|
||
identity the day it exists, so the imagery stays true rather than becoming a period piece.
|
||
The name says "Demo" deliberately: nobody should have to wonder whether they are looking at
|
||
a server they could join.
|
||
|
||
**D44 — the captures sit beside the claims they support, in two places.** A figure set on
|
||
`/features/`, one on the homepage, and inline shots on the phase-7 administration pages that
|
||
describe a screen in prose. Eleven web captures.
|
||
|
||
The administration pages are where a screenshot does the most work, because phase 7
|
||
described thirteen screens it could not show. A dedicated `/screenshots/` gallery was
|
||
rejected for the reason galleries usually are: a page nobody visits does less than a figure
|
||
sitting under the sentence it proves.
|
||
|
||
**D45 — the rig is committed, not remembered.** Three files rather than a folder of images:
|
||
`scripts/seedDemo.mjs` puts the content there by driving the site's own API,
|
||
`src/data/screens.mjs` declares every capture with its route, viewport, scroll offset and
|
||
caption, and `scripts/captureScreens.mjs` turns the second into files.
|
||
`scripts/checkScreens.mjs` is the ninth check script and runs in CI.
|
||
|
||
The argument is the same one D35 made for the install quickstart: the way real screenshots
|
||
rot is that the recipe for taking them lives in somebody's memory. Re-taking the set after a
|
||
redesign is now `npm run screens:capture`, and the check fails if an entry has no file, a
|
||
file is the wrong size, a file is orphaned, or a declared screen is rendered nowhere.
|
||
|
||
**Why the seed drives the API and never the database.** Every row it creates could have been
|
||
an `INSERT`, and every `INSERT` would be a second implementation of a rule the website owns —
|
||
how a body is sanitized, which excerpt is derived, how a password is hashed. A seed that
|
||
writes SQL produces a database the product could not have produced, and screenshots of that
|
||
database show a product that does not exist.
|
||
|
||
**D46 — the world gets dressed in `servuo-plugins/tools`.** `BridgeSeeder` builds a world at
|
||
realistic scale; it never needed the world to look like anything, so a vendor traded as
|
||
"Seed Shop 810" and a character was "Seed004A" — and every one of those strings travels the
|
||
whole bridge and lands on the marketplace, the guild roster and the housing pages.
|
||
`BridgeDemoDress` renames them in place and seeds nothing, drawing names from fixed tables
|
||
hashed off each object's serial, so a re-run reproduces the same world and a screenshot can
|
||
be retaken later and still match.
|
||
|
||
**What the rig found.** A screenshot rig is an integration test with a human in the loop, and
|
||
this one turned up six things nothing else had:
|
||
|
||
- **A fresh `module-uo` install pinned wire protocol 3 while the sidecar speaks 4**, so a new
|
||
deployment 409s on every shard read until an admin edits the number by hand. The protocol-4
|
||
cutover bumped `link`, `servuo-plugins` and `docs` and missed the module's own default.
|
||
Fixed upstream and released as `module-uo` **v1.0.2** — which is what this repository's own
|
||
facts check then noticed, since `platform.json` still said v1.0.1.
|
||
- **A renamed guild member never reaches the site.** The plugin folds name, abbreviation,
|
||
leader, member count and alliance into the signature it compares, and re-emits the roster
|
||
only when the member *set* changes — so renaming a member leaves the published roster stale
|
||
indefinitely.
|
||
- **A guild deleted while the shard is offline is a ghost row forever.** The "gone" pass
|
||
compares against a cache that is cleared on reconnect, so nothing emits `guild.remove`. The
|
||
demo's guild board was showing two guilds the world no longer had, a week after they went.
|
||
- **"Houses in danger" cannot show a house that was already collapsing.** The ingest writes
|
||
that column only from the `house.decay` transition feed, while the registry frame's stage is
|
||
deliberately left alone so the two cannot clobber each other. A house already in IDOC when
|
||
the site connects is therefore invisible — the page said none while the shard had two.
|
||
- **The Android news list prints raw ISO timestamps.** Found while choosing the phone
|
||
captures; the news screen was dropped from that set rather than shipping a picture of it.
|
||
- **The app says "1 players online".** `shard_online_count` and `ShardEventText.kt` both
|
||
interpolate a count into a fixed plural. Found in the retake after a character was signed in,
|
||
and it is in the shipped phone capture — a `plurals` resource is the fix, in the app.
|
||
|
||
The first is fixed. The rest are raised as product observations, with the rig working around
|
||
them: the guilds are built *after* the rename, and the IDOC staging is two passes with a wait
|
||
between them so the site watches the collapse happen. All of that is scaffolding under
|
||
`servuo-plugins/tools/`, which is never deployed.
|
||
|
||
**The emulator pass (D26), and the AVD that would not take it.** The six phone captures come
|
||
from an emulator pointed at the same deployment on the same day, signed in as an ordinary
|
||
player, reached through `adb reverse` — the app's debug network policy permits cleartext to
|
||
`localhost` only, which is a better default than the one that would have made `10.0.2.2`
|
||
work. The device is API 35 rather than the API 36 the plan named: the API 36 image on this
|
||
machine had 200 MB free and refused the install, and wiping somebody's development device to
|
||
take a screenshot is not a trade worth making.
|
||
|
||
The shard screen is the one worth having. It shows the two houses entering IDOC in its live
|
||
activity feed — the same event that reached `/uo/houses` in the browser, on the same rig, in
|
||
the same minute.
|
||
|
||
**The character that had to be logged in by hand.** The org lead asked for a player in the
|
||
world, and the scaffolding does its half — it sets a known password on a seeded account,
|
||
because `BridgeSeeder` gives every account a random GUID nobody kept. Driving the client is
|
||
where automation stopped. ClassicUO stores its password crypted, so a plaintext one in
|
||
`settings.json` decrypts to garbage and auto-login fails; posted mouse clicks reach the client
|
||
but posted text does not; and the remaining route — taking the foreground and typing — was
|
||
tried once, failed to take focus, and typed into the browser window the person at this machine
|
||
was using. It was not tried again.
|
||
|
||
The org lead signed in instead, and the two frames that depended on it were retaken: the shard
|
||
page now reads **1 player online, in Britain**, and the app's shard card agrees. Both came from
|
||
the same `npm run screens:capture shard-status app-shard`, which is the whole point of D45 —
|
||
the thing that changed was the world, not the recipe.
|
||
|
||
Two things that pass is worth noticing here. Presence reaches the public page as **counts and
|
||
regions, not names**, which is the visibility framework doing its job unprompted. And the
|
||
**guild board's "online" column did not move**: it is refreshed only when a guild's signature
|
||
changes, which is the same defect as the stale roster above wearing a different hat.
|
||
|
||
**A layout decision worth recording.** The `/features/` figures are one-up at the column's
|
||
full width, not a two-column grid. Two-up was built first and is the obvious layout for a set
|
||
of figures — but these are screenshots of a dense interface, and halving the width puts the
|
||
product's own type at about a third of its real size, which reads as a thumbnail of something
|
||
rather than a picture of it. A long section of legible evidence beats a tidy grid of
|
||
unreadable tiles.
|
||
|
||
---
|
||
|
||
## 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/checkScreens.mjs`** — added in phase 9 for D45. `src/data/screens.mjs` is the one
|
||
list of what the site shows of itself, and this proves every entry has a file at the size the
|
||
markup declares, that nothing in `public/screens/` is orphaned, and that every declared
|
||
capture is rendered somewhere. The size half is the one that repays it: a re-capture taken at
|
||
the wrong viewport looks perfectly fine on its own and only reveals itself as a page that
|
||
reflows while it decodes. No browser and no game server — the capture tool is an authoring
|
||
script whose output is committed, exactly like the brand assets.
|
||
- **`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 D20–D25 |
|
||
| **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) — twenty pages in one PR (D38), with Reference enumerating names and **checking every one of them** against its source (D39), and `plannedSidebar` becoming a checked invariant (D41) |
|
||
| **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. See D47–D50 — the CSP was the work, because `@astrojs/node` served every page another page's policy |
|
||
| **11** | Validation: `astro check`, production build, **all eleven check scripts** (tokens, brand, links, facts, quickstart, data safety, reference, sidebar, screens, a11y, CSP) plus both test suites, 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
|