All checks were successful
PR checks / checks (pull_request) Successful in 2m30s
platform.json's androidApk.serviceable goes to true. It was false because v0.5.0's build did not work. v0.7.0's released APK (SHA256 matches SHA256SUMS) was installed on an Android 16 emulator, connected to the public demo and read its Rust servers, so /app/ links the download again. The comment beside the flag now says when it was last checked and to set it back to false when `asset` moves to a release nobody has run. PLAN.md D29 gets a dated note. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2557 lines
181 KiB
Markdown
2557 lines
181 KiB
Markdown
# runicgateway.com — design of record
|
||
|
||
**Status:** phases 0–14 are built (13 and 14 as part of revision 4). **Revision 4 (2026-10-22) plans phases 13–18** —
|
||
the second game, RunicNPC, and taking game vocabulary out of the site at large — in §16.
|
||
**Date:** 2026-08-19 (revision 3) · 2026-10-22 (revision 4)
|
||
**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)
|
||
16. [A second game: Rust, RunicNPC, and a vocabulary rule](#16-a-second-game-rust-runicnpc-and-a-vocabulary-rule)
|
||
|
||
---
|
||
|
||
## 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. **Amended by D54 (2026-08-25):** a merge to `main` now builds, publishes and deploys. What D6 protected is held by `needs: build` and by every check having run on the pull request. |
|
||
| **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. **Built 2026-10-27** as `demo.runicgateway.com`, on Rust with a two-hour reset; the site reaches it through D82 and D83 (§15). |
|
||
| **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 **eighty-four**:
|
||
|
||
| # | Where | What it settled |
|
||
|---|---|---|
|
||
| D14–D16 | §7, "How phase 2 actually built it" | The branding pipeline: one raster in, brand text applied at boot, the mark is the real emblem |
|
||
| D17–D19 | §10, "How phase 3 built the homepage" | The data-path diagram, all five groups on the homepage, the emblem-led hero |
|
||
| D20–D25 | §10, "How phase 4 built the marketing pages" | `/features/` as the same list with detail, `/architecture/` as reasons not reference, the absences as data, the two absorbed scope items, `needsModule`, the demo deep links |
|
||
| D26–D29 | §8, "How phase 5 built the app and the beta" | The screenshot slot reserved for phase 9, the demo as the tester target, `/beta` handling its own POST, equal billing for the APK and the beta |
|
||
| D30–D33 | §9, "How phase 6 built the legal pages" | One logging hop and no edge provider, eighteen or older, no governing-law clause, the Data Safety notes as a generated document |
|
||
| D34–D37 | §10, "How phase 7 built the documentation journey" | One PR for all twenty pages, a self-contained install quickstart with a drift check, every admin screen walked before it was described, a thirteenth Administration page for content |
|
||
| D38–D41 | §10, "How phase 8 built the builder and reference docs" | One PR for all twenty pages again, Reference enumerates names and checks every one of them, the docs section links to the drawn diagrams rather than importing them, `plannedSidebar` becomes a checked invariant |
|
||
| D42–D46 | §10, "How phase 9 took the screenshots" | The full rig behind the imagery, a neutral demo brand, the captures beside the claims, a committed and checked capture pipeline, the world dressed in the plugin repo's scaffolding |
|
||
| D47–D50 | §6, "How phase 10 polished it" | Search reaches the marketing pages, the CSP is a real response header from the container, `robots.txt` allows everything and names the sitemap, two blocks of structured data and no more |
|
||
| D51–D53 | §6, "How phase 11 validated it" | The chrome and the head follow the brand mount while the consent sentence does not, the documentation half gets phase 10's skip-link fix, and no twelfth check |
|
||
| D54–D57 | §6, "How phase 12 delivered it" | A merge deploys (amending D6), the proxy is documented by its requirements rather than by an example, the container trusts the forwarded headers with nothing to configure, and the operator note is its own file while the two policy files are pointers |
|
||
| D58 | §6, "How phase 12 delivered it" | `node_modules` ships in three layers because Cloudflare refuses a request body over 100 MB, and the workflow counts layers before it pushes |
|
||
| D59 | §6, "How phase 12 delivered it" | The container publishes on every interface (amending D55), with `SITE_BIND_ADDR` to narrow it |
|
||
| D60–D65 | §16, "The six decisions" | Per-game sidebar groups, RunicNPC inside Rust with its own page, the RunicNPC API carried in full with a drift check, game names allowed but game vocabulary confined, a twelfth check that enforces it (amending D53), Rust screenshots last and from the rigs |
|
||
| D66 | §16, "How phase 13 built it" | The hero and footer show the Module API only; a protocol and a bundle belong to a game |
|
||
| D67 | §16, "One rule the phases need" | A screenshot belongs to its game: a capture tagged with a game may carry its words, beside the game's name |
|
||
| D68–D69 | §16, "How phase 14 built it" | A game's real example on a core page is a tagged, labelled block; *Verify the whole stack* stays in Getting started, game-neutral (amending D60) |
|
||
| D70–D71 | §16, "How phase 15 built it" | The Rust group is fifteen pages, one per admin screen where it can be; the Rust event kinds are checked against the module's own classification |
|
||
| D72–D74 | §16, "How phase 16 built it" | The RunicNPC subgroup is eight pages with a shared profile-field reference; the API check compares signatures and every data shape's field names; the site's API page carries the current API only, with no decision numbers and no version history |
|
||
| D75–D79 | §16, "How phase 17 built it" | Core marketing pages describe what a game module adds in core's words and link to each game's page; the homepage keeps its game screenshot, labelled; `/app/` describes the Rust screens, released the same day; `/runicnpc/` is in the header; `checkTerms.mjs` gains a pass over the built pages |
|
||
| D80–D81 | §16, "How phase 18 built it" | A name in a screenshot is a role; the Rust captures' players are mock data |
|
||
| D82–D83 | §15, "How the site reaches it" | `/beta` lists the demo's sign-ins as the demo publishes them; a demo deep link names its game and shows only for the games the mounted `demoGames` lists |
|
||
| D84 | §9, "How phase 6 built the legal pages" | The Data Safety answers say "collected, not shared" for everything the app sends, amending D33 |
|
||
| D85–D86 | §9, "`/delete-account/`" | The deletion page is linked from the footer, `/privacy` and `/app/`; someone who cannot sign in is sent to their community's operator, never to us |
|
||
|
||
---
|
||
|
||
## 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.
|
||
|
||
### How phase 11 validated it
|
||
|
||
Three decisions, D51–D53, taken 2026-08-25. The phase's list in §13 is a list of things to run, and
|
||
running them was the easy half: `npm run verify` was green on `main` before this phase started and
|
||
is green now. The half that mattered was the part no script does — a real browser, at three widths,
|
||
across every page, and a signup and a brand mount walked end to end against the built server.
|
||
|
||
That found three things, two of which are fixed here. All three are recorded, including the one that
|
||
was left, because a defect nobody wrote down is one the next phase re-discovers.
|
||
|
||
**What was run.** Fourteen steps: `astro check`, the production build, all eleven checks (sidebar,
|
||
screens, tokens, brand, data safety, links, facts, quickstart, reference, a11y, CSP) and both test
|
||
suites — 36 tests offline and 5 against a running server. Then, outside CI: fifty pages at 390, 768
|
||
and 1280 in the installed Chrome; the signup's whole decision path against a scratch store; the
|
||
export CLI including a deletion; a full brand mount, applied and restarted; and every off-site link
|
||
the built site publishes.
|
||
|
||
**The browser walk came back clean on everything phase 10 had claimed.** No horizontal overflow at
|
||
any width on any page. No CSP violation anywhere — the wrapper in `scripts/serve.mjs` holds for all
|
||
fifty policies. No failed request, no image without an `alt`, no image that failed to decode, and no
|
||
console error other than the 404 page's own 404. Search opens, reaches both chromes, and closes on
|
||
Escape at both widths; the documentation theme switcher and mobile sidebar work, which is the thing
|
||
a wrong CSP breaks first and silently; twelve tab stops on the homepage all draw a focus ring and
|
||
follow the visual order.
|
||
|
||
**D51 — the chrome and the head follow the mount; the consent sentence does not.** The brand walk
|
||
mounted a complete `brand.json` — a different site name, tagline, contact address, Discord invite,
|
||
Gitea org, demo URL and Play opt-in URL — restarted, and asked every page whether any stock string
|
||
survived. Forty-nine came back clean. `/beta` did not.
|
||
|
||
The reason is structural and was written down in `brand.mjs` before it was true: `applyBrand.mjs`
|
||
rewrites files in `dist/client`, and `/beta` renders per request, so its HTML never exists as a file
|
||
to rewrite. `liveBrand()` was added in phase 5 for exactly this, and `/beta` used it — for
|
||
`betaOptInUrl`, and nothing else. Everything *around* the form came from the shared chrome, and the
|
||
shared chrome was baked: the page's `<title>`, its `og:site_name`, `og:title` and `og:image:alt`,
|
||
the header lockup, and the footer's Source and Discord links. On a mounted deployment the one page
|
||
that asks a person for their address under a stated identity was the one page still stating the
|
||
wrong one.
|
||
|
||
Both accessors were right for a page that renders one way. The chrome is neither, so
|
||
`renderBrand(Astro)` picks by `Astro.isPrerendered` — the stock value where the boot rewrite will
|
||
reach it, the mount where it will not — and `Base.astro`, `Header.astro` and `Footer.astro` call it.
|
||
Doing it by discriminator rather than by calling `liveBrand()` everywhere matters: unconditional
|
||
live reads would also change the forty-nine, where a build machine that happened to have a mount
|
||
would bake mounted text into HTML the boot rewrite then has nothing to replace.
|
||
|
||
**`CONSENT_TEXT` is deliberately excluded.** It names the operator of the list inside a sentence a
|
||
person agrees to, and it is stored verbatim in their row — so making it follow a mounted name would
|
||
change the recorded text of a consent already given. It stays a constant and moves only with a
|
||
consent-version bump, which `test/legal.test.mjs` already enforces.
|
||
|
||
Two strings on `/beta` are knowingly left as they are: "A Runic Gateway deployment to connect to"
|
||
and "If you already run a Runic Gateway deployment". Both name the *platform the app connects to*
|
||
rather than the operator of this site, which is the one thing on the page a rebrand does not change.
|
||
|
||
**D52 — the documentation half gets phase 10's skip-link fix.** Phase 10 found that following the
|
||
skip link moved the viewport but not the keyboard focus, because a `<main>` is not focusable, and
|
||
fixed it on the marketing chrome. The walk found the identical defect standing on the other forty
|
||
pages: Starlight's skip link targets the page `<h1>`, and an `<h1>` is no more focusable than a
|
||
`<main>`. A `PageTitle` override adds `tabindex="-1"` and suppresses the ring on an element that is
|
||
reachable by exactly one deliberate route and is never in the tab sequence. It is Starlight's own
|
||
implementation with one attribute added, because the override mechanism replaces a component rather
|
||
than decorating it; the drift risk that creates is named in the file.
|
||
|
||
**The heading anchor links were checked and left.** Starlight's ¶ links measure 23.98×34.8 beside an
|
||
`h2` and 19.86×28.8 beside an `h3` at 390px, under WCAG 2.2 SC 2.5.8's 24px. They are exempt under
|
||
that criterion's *Equivalent* clause: the mobile table of contents on the same page offers a
|
||
388×34.5 link to every one of the same anchors. Recorded rather than fixed, so the next walk does
|
||
not re-raise it.
|
||
|
||
**D53 — no twelfth check.** Two of the phase's rigs were good enough to be tempting. An external
|
||
link sweep found 73 of 74 destinations alive (the 74th, gnu.org's licence text, is unreachable from
|
||
this network rather than gone), and a brand-mount walk would have caught D51 and would guard it. Both
|
||
were left as throwaway scripts. A link check makes the build depend on other people's uptime, and a
|
||
browser walk needs Chrome on the runner — CI would gain two ways to be red for reasons that are not
|
||
about this repository. The eleven checks stand, and what the rigs found is written down here instead.
|
||
|
||
**What the signup walk proved.** Every branch of the decision path, each against the store rather
|
||
than against the page it renders: an address is added and its consent text stored verbatim; the same
|
||
address again is a duplicate and not a second row; a filled honeypot stores nothing and is
|
||
indistinguishable from success; a malformed address and an unticked consent box are both refused on
|
||
the server with the browser's validation disabled; an unsigned form token stores nothing; and a
|
||
burst is stopped at `perHour` with a message that says the count is attempts rather than signups.
|
||
The `minSeconds` gate is real enough to be worth knowing about — the first pass of the walk recorded
|
||
four `too-fast` rows and nothing else, because a script fills a form faster than a person can.
|
||
|
||
The export CLI writes both files Play needs, marks the rows exported, refuses to re-export without
|
||
`--all`, and `remove` overwrites the address, the IP hash and the user agent rather than flagging
|
||
the row — which is what `/privacy` promises.
|
||
|
||
**And the mount itself.** The rewrite reached 51 files and re-indexed all 50 pages for search, so
|
||
results agree with the pages. `/brand/*` served the mounted `theme.css` and fell back per key to the
|
||
default `wordmark.svg`. Three path-traversal shapes were refused. The demo slot opened and twelve
|
||
per-capability deep links pointed into the mounted demo. And an opt-in URL pasted into the mounted
|
||
file reached the confirmation screen on the next request, with no restart — which is the one part of
|
||
§7 that has to be true on the day the closed test opens.
|
||
|
||
### How phase 12 delivered it
|
||
|
||
The last phase, and the one that turns a repository into a deployment: a two-stage `Dockerfile`, a
|
||
pull-only `docker-compose.yml` carrying both bind mounts, `.env.example`, the publishing workflow,
|
||
`CONTRIBUTING.md` with the AI-disclosure requirement, the community-health files this repository was
|
||
the only one in the organisation to lack, and `DEPLOY.md`. Four decisions, **D54–D57**, taking the
|
||
count of record to **fifty-seven** — plus **D58**, added when the merge that shipped the phase could
|
||
not publish its own image, and **D59**, which amends D55's loopback binding. **Fifty-nine.**
|
||
|
||
It also found a defect that would have made the closed beta impossible, and it is the only phase
|
||
that could have found it. Everything before this ran the site the way a developer runs it: one
|
||
process, plain HTTP, an origin the browser and the server agree about by construction. The site does
|
||
not run that way. Standing a real container behind a real proxy is a different question, and it had
|
||
a different answer.
|
||
|
||
#### D54 — a merge deploys, which amends D6
|
||
|
||
D6 said "ship the image and the compose file; the org lead deploys", and it was written before there
|
||
was a host to deploy to. The org lead amended it on 2026-08-25: `.gitea/workflows/build-image.yml`
|
||
builds, pushes `runicgateway-site:latest` and `:sha-<7>`, and then rolls the container over on a
|
||
runner labelled **`rgcom`** on the site's own host, out of **`/opt/runicgateway.com`**. There is no
|
||
release step and no promotion, so **a merge is a publication**.
|
||
|
||
What D6 was protecting is held by something else now. Every one of the eleven checks and both test
|
||
suites have already run on the pull request; the deploy job is `needs: build`, so a failed build
|
||
never reaches the host at all; and the job then waits for the container's own healthcheck rather
|
||
than for `up -d` to return, because `npm start` runs the brand rewrite *before* the server starts and
|
||
"running" therefore arrives well before "serving". A pinned `IMAGE_TAG` survives an automatic
|
||
deploy — Compose recreates on the pinned tag — because a pin is a decision and a merge should not
|
||
quietly undo it.
|
||
|
||
The runner registers with the label `rgcom:host`. The `:host` suffix is what makes jobs run on the
|
||
machine rather than inside a job container; without it the deploy fails on the `cd`, having no
|
||
Docker socket and no compose directory.
|
||
|
||
#### D55 — a generic proxy, documented by its requirements rather than by an example
|
||
|
||
The site runs on its own host, behind whatever reverse proxy the org lead puts there. So
|
||
`DEPLOY.md` does not carry a worked Caddyfile or nginx block that would be wrong for three readers
|
||
out of four; it states the four things the proxy must do. The container originally bound to
|
||
`127.0.0.1` by default so that the safe configuration was the default one; **amended by D59
|
||
(2026-08-26)** — it publishes on every interface, and which interfaces is a variable.
|
||
|
||
Two of the four are worth repeating here because they are silent when wrong:
|
||
|
||
- **`X-Forwarded-For` is load-bearing.** The signup rate-limits per client from the first entry of
|
||
that header, falling back to the connection's peer address. Behind a proxy that does not set it,
|
||
the peer address is *the proxy* — so every visitor on earth shares one bucket and the third signup
|
||
of any hour closes the form for everybody. It fails toward refusing signups rather than toward
|
||
accepting abuse, which is the right direction and still a broken page.
|
||
- **A second Content-Security-Policy from the proxy breaks every page.** Browsers enforce the
|
||
intersection of duplicate policies, and this site's is a list of per-page hashes, so a generic
|
||
policy added at the proxy forbids the page's own stylesheet. The container already sends CSP,
|
||
`X-Content-Type-Options`, `Referrer-Policy`, `X-Frame-Options` and `Permissions-Policy` (D48), so
|
||
the proxy's job is to add none of them. The same paragraph names the three Cloudflare features
|
||
that rewrite HTML — Rocket Loader, HTML minification and email obfuscation — because each of them
|
||
breaks the hashes in exactly the same way, from a dashboard rather than from a config file.
|
||
|
||
#### D56 — the container trusts the forwarded headers, with nothing to configure
|
||
|
||
**The defect.** `@astrojs/node` builds the URL of every request from the connection and the `Host`
|
||
header alone. In `astro/app/node`'s `createRequestFromNodeRequest`:
|
||
|
||
```js
|
||
const isEncrypted = "encrypted" in req.socket && req.socket.encrypted;
|
||
const protocol = isEncrypted ? "https" : "http";
|
||
```
|
||
|
||
`x-forwarded-proto` is never consulted on that path — and `security.allowedDomains`, which sounds
|
||
like the answer, is not: on this code path it gates only whether `Astro.clientAddress` may come from
|
||
`x-forwarded-for`. Behind a proxy that terminates TLS, the browser sends `Origin:
|
||
https://runicgateway.com` and the container computes `http://runicgateway.com`, because its own
|
||
socket is plaintext. Astro's CSRF middleware then compares the two for equality:
|
||
|
||
```js
|
||
const isSameOrigin = request.headers.get("origin") === url.origin;
|
||
```
|
||
|
||
So **every beta signup, from every visitor, is answered `403 Cross-site POST form submissions are
|
||
forbidden`** — on the one route that accepts a POST, on the site whose nearest real deadline is a
|
||
closed test that cannot start without it. No proxy configuration fixes it; a proxy cannot make the
|
||
container's socket encrypted. Fifty pages look perfectly healthy while the form silently refuses
|
||
everyone.
|
||
|
||
`scripts/serve.mjs` already exists to wrap this adapter's mistakes (D48), so the fix went there:
|
||
`x-forwarded-proto: https` marks the socket encrypted, and `x-forwarded-host` replaces `Host` for
|
||
the proxies that rewrite it to the upstream address instead of passing it through.
|
||
|
||
**Both are trusted unconditionally, with no flag to set** — the org lead's call, and the right one.
|
||
The image is meant to be deployed and work; an operator who has to discover a `TRUST_PROXY` variable
|
||
to make the signup work is an operator who ships a dead form, and the broken configuration would be
|
||
the default. It costs nothing: a cross-site form submission cannot make a victim's browser send
|
||
`x-forwarded-proto`, so the CSRF check is exactly as strong as it was, and the site has no cookie,
|
||
session or credential to protect in the first place. Two assertions in `test/headers.test.mjs` hold
|
||
both halves — a proxy-shaped POST is accepted, and a genuinely cross-origin one is still refused.
|
||
|
||
#### D57 — the operator note is its own file, and two policies are pointers
|
||
|
||
`DEPLOY.md` rather than a README section: the README is for someone working *on* the site and was
|
||
already long, and deployment is a different task for a different sitting — it is also the file that
|
||
gets opened on the host. It carries what the site needs, first deploy, the proxy, DNS and TLS,
|
||
branding without a rebuild, the tester-list CLI, updating, rolling back, backups, and a symptoms
|
||
table.
|
||
|
||
This repository was also the only one of the ten with no `CONTRIBUTING.md`, no
|
||
`CODE_OF_CONDUCT.md`, no `SECURITY.md` and no issue or pull-request templates, so phase 12 added
|
||
them. **The two policy files are pointers to the organisation's copies in `docs`, not copies** —
|
||
because a copy would hard-code the contact address in a tenth place, and D13's whole promise is that
|
||
the address lives only in `brand.json` and moves for the cost of a file copy. `checkFacts.mjs` scans
|
||
`src/` and `scripts/`; these files honour the same rule voluntarily, and say so, so that the next
|
||
person does not "fix" the missing address.
|
||
|
||
`SECURITY.md` is not only a pointer, though. It names what is actually worth reporting *here* —
|
||
the signup's store, rate limit and signed form token; path traversal out of the branding mount; and
|
||
a page served no policy or another page's — and states plainly that there is no authenticated
|
||
surface to attack.
|
||
|
||
**No twelfth check.** D53 was re-tested against this phase's new drift risk — the environment
|
||
variables the code reads, `.env.example` declares and the README tabulates — and the org lead held
|
||
the line. Eleven checks stand.
|
||
|
||
#### D58 — the image ships in three layers, because of the registry rather than the site
|
||
|
||
The merge that landed phase 12 could not publish the image it had just built. `docker push` answered
|
||
**`413 Payload Too Large`** on one blob and stopped; nothing reached the registry, and `needs: build`
|
||
meant nothing reached the host either. The site was merged and undeployed, and the log said only that
|
||
a digest was too large.
|
||
|
||
**The limit is Cloudflare's, not Gitea's.** `gitea.whitlocktech.com` is proxied, and Cloudflare
|
||
refuses a request body over **100 MB** on every plan below Enterprise — a plan limit, not a setting.
|
||
`docker push` uploads each layer as a single monolithic `PUT`, so the ceiling applies per layer, and
|
||
the rejection happens at the edge with Gitea never seeing the request. It is invisible from the
|
||
Gitea side and unfixable from it.
|
||
|
||
Measured on the merge commit, one layer was over and only just: **`COPY node_modules` at 108.8 MB
|
||
compressed**, against a 188.6 MB image whose next largest layer was the 47.6 MB Node base. Two
|
||
packages account for it, `@pagefind` (the search binaries) and `@img` (sharp's libvips), and both are
|
||
needed at **run** time — the boot rewrite re-indexes the site and re-derives the brand images — so
|
||
what could move was where they land, not whether they ship.
|
||
|
||
The build stage now moves those two aside after `npm prune`, and the runtime stage copies them as
|
||
their own layers: **46.8 + 50.5 + 11.5 MB** in place of 108.8, largest layer 50.5, and the image
|
||
**exactly the same total size**, because the same bytes are simply divided differently. Moving rather
|
||
than copying twice is what keeps the three disjoint — whatever remains in `node_modules` is the
|
||
remainder by construction, so a dependency added later needs no maintenance here.
|
||
|
||
**The workflow now counts layers before it pushes**, because a split is a margin and not a
|
||
guarantee: `docker save`, re-compress anything over 8 MB the way the push would, and fail at **90 MB**
|
||
— not 100, since the blob is not the only thing in the request — naming the layer and what would
|
||
have happened. It was tested in both directions, against the fixed image and the broken one, and the
|
||
number it reports for the broken layer (108 MB) agrees with what the registry recorded.
|
||
|
||
This is a workflow step and not a twelfth check script: it needs a built image rather than a source
|
||
tree, which is the one thing the eleven never have. D53 holds.
|
||
|
||
#### D59 — the container publishes on every interface, which amends D55
|
||
|
||
D55 bound the published port to `127.0.0.1`, reasoning that TLS terminates at a proxy on the same
|
||
host and nothing else has business reaching the container. That is the right default for the host
|
||
this eventually runs on, and the wrong one for every step before it: a loopback binding cannot be
|
||
opened from a browser on another machine, which is exactly what an operator wants to do first —
|
||
look at the thing on the VM's own address, before DNS exists, before the proxy exists, from a
|
||
desktop or a phone that is not the VM.
|
||
|
||
The org lead settled it on 2026-08-26: **publish on all interfaces**, the way a normal bridge
|
||
publish behaves. `docker-compose.yml` now reads
|
||
`"${SITE_BIND_ADDR:-0.0.0.0}:${SITE_HOST_PORT:-4321}:4321"`, so the site answers on
|
||
`http://<vm-ip>:4321` out of the box.
|
||
|
||
**Which addresses it answers on is a variable, not an edit.** `SITE_BIND_ADDR` in `.env` narrows it
|
||
to one interface or back to loopback without touching a file that `docker compose pull` replaces,
|
||
which also removes the "change the port line" instruction D55 had to give proxies running in another
|
||
container or on another machine.
|
||
|
||
What is honestly given up: on a host with a public address, port 4321 answers from the internet
|
||
directly — plain HTTP beside the proxy's 443, and with no proxy in the path to set `X-Forwarded-For`,
|
||
so signups arriving that way share one rate-limit bucket. The site has no login and no secret behind
|
||
it, so this is untidy rather than dangerous, and `DEPLOY.md` §3.1 says so and gives both remedies
|
||
(firewall the port, or narrow the binding).
|
||
|
||
---
|
||
|
||
## 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.
|
||
*(2026-10-31: the link is back on. Android `v0.7.0`'s APK was installed on an Android 16 emulator,
|
||
connected to the public demo and read its Rust servers, so `serviceable` is `true`.)*
|
||
|
||
**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. *Phase 11 found it had been applied to one
|
||
field and not to the chrome around it, and added `renderBrand()` — see D51.*
|
||
- **`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.
|
||
|
||
**D84 — The Data Safety answers say "collected" for everything the app sends** (org lead,
|
||
2026-10-29, Android `PLAN.md` M8; amends D33). Play counts data that leaves the device as
|
||
collected, whoever runs the server it lands on, so "not collected because we run no server" was the
|
||
wrong answer to the form's question even though it is the right sentence for `/privacy`. An audit of
|
||
the app's `edge` (every Retrofit body, query and header in `data/api/`) found more leaving the phone
|
||
than section 2 listed: the sign-in itself, the device's make and model on a trusted-device request,
|
||
the User-Agent, game-account links and in-app game-account creation, the Contact form's name, email
|
||
and message, staff posts and moderation, notification read state and preferences, and search text.
|
||
Each is now a row, declared **collected, not shared, encrypted in transit, required**; the rows
|
||
that stay on the phone say so and carry no console type. `PlayMapping` takes a list of `types`
|
||
(checked against `PLAY_DATA_TYPES`, the console's own vocabulary, which caught the original file
|
||
filing "Other user-generated content" under Messages), `required` and `purposes`; the generator
|
||
folds rows into one answer per data type, lists every type that is not collected, and answers the
|
||
form's security questions. D33's test now asserts the part of the premise that is still true:
|
||
**nothing is shared**. `/privacy` section 2 keeps saying none of it reaches us, and `lastUpdated`
|
||
moved with it. Report, block and hide (Android M8 step 3) adds its rows when it lands.
|
||
|
||
### `/delete-account/` (2026-10-30)
|
||
|
||
Play needs one account-deletion address from whoever publishes the app. The platform gained
|
||
self-service deletion on the website and in the app (`docs/website/ACCOUNT_DELETION.md`, AD1), and
|
||
this page is the address Play is given (AD2, §4.5 there). It cannot delete anything: we hold no
|
||
account, so it explains where the button is in the app and on the website, what goes and what stays
|
||
and for how long, and who to ask when you cannot sign in.
|
||
|
||
**The facts are one row.** What a deletion takes is the `deploy-account-deletion` row in
|
||
`src/data/collection.mjs`, whose `deletion` field holds the website's own words for what goes and
|
||
what stays (`client/src/lib/accountDeletion.js`, which the app's strings repeat) plus how long each
|
||
kept thing is kept, which Play asks and the screens do not say. `/privacy` section 3 renders the row
|
||
as prose, the page renders its lists, and `PLAY_DATA_SAFETY.md` prints them under the console's
|
||
deletion questions, so the three cannot disagree. `deletionRow()` throws when the row is missing,
|
||
and `test/legal.test.mjs` fails a kept entry with no retention. The app rows that deletion touches
|
||
(the session, the trust token, push and the inbox snapshot) say so, and `lastUpdated` moved.
|
||
|
||
**D85 — The page is linked from the footer, `/privacy` and `/app/`** (org lead, 2026-10-30). The
|
||
footer's legal bar reads Privacy · Terms · Delete account, because a player looking for it looks
|
||
where the privacy link is; `/privacy` sections 2 and 3 and the bottom of `/app/`'s "Two ways to get
|
||
it" link to it as well.
|
||
|
||
**D86 — Someone who cannot sign in is sent to their community's operator, never to us** (org lead,
|
||
2026-10-30). The page offers "Forgot your password?" first, which works when the account has an
|
||
email address and the site can send mail, and then the operator, who can delete any account from the
|
||
admin panel. It says plainly that we cannot do it: we hold no account and have no access to any
|
||
community's site. Offering our contact address to help find an operator was considered and declined.
|
||
|
||
**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 |
|
||
| `/delete-account/` | §9: how to delete an account you use in the app, and what it keeps. The address Play is given (D85, D86) |
|
||
|
||
**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, per game | the game's sidecar repo (`link`, `Rust-Link`) `main:sidecar/src/main.rs` → `PROTOCOL_VERSION` |
|
||
| Plugin protocol, per game | the game's plugin repo (`servuo-plugins`, `Rust-Plugins`) `main:overlay.toml` → `protocol` |
|
||
| Module API | `website` `main:server/src/modules/version.js` → `MODULE_API_VERSION` |
|
||
| Module capabilities + `coreApi`, per game | `Module-uo` / `Module-Rust` `main:module.json` (capabilities added in phase 3, both games and `coreApi` in phase 13) |
|
||
| Bundle + component pins, per game | `installer` branch `bundles`, **`v2/servuo/current.json`** and **`v2/rust/current.json`** (phase 13; the root `current.json` is the frozen schema-1 manifest) |
|
||
| RunicNPC API, floors, required plugins | `runicnpc-rust` `main:plugin.toml`, and the Rust bundle's `npc` block (phase 13) |
|
||
| 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.
|
||
|
||
**All three network checks read a file through Gitea's `contents` endpoint, never `raw`** —
|
||
`checkFacts.mjs`, `checkQuickstart.mjs`, `checkReference.mjs`. Phase 12b found the reason.
|
||
`raw` answers with `Cache-Control: public, max-age=21600`, so the CDN in front of Gitea keeps
|
||
a copy for six hours: on cutover day this check read `website`'s `version.js` from a fortnight
|
||
earlier and failed the site for saying Module API 1.9.0 when `main` said 1.6.0 — except that
|
||
`main` said 1.9.0, and nothing anyone could edit here would have made it pass. `contents`
|
||
answers `private, must-revalidate` and is not cached, at the cost of a base64 decode. Same
|
||
argument as `checkLinks.mjs` fetching nothing: a check that goes red on someone else's
|
||
infrastructure is a check people learn to ignore, and one that goes red on a stale copy is
|
||
worse — it is indistinguishable from the failure it exists to report.
|
||
- **`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).
|
||
- **`scripts/checkTerms.mjs`** — added in phase 14 for D63 and D64, the twelfth check. No game's
|
||
vocabulary outside that game's section: its docs group, its sidebar group, or a
|
||
`<GameExample>` block (D68). Reads the docs and both sidebar trees before the build; since
|
||
phase 17 (D79) a second pass, `--built`, reads every built page's visible text after it, so
|
||
the marketing pages and the words their data renders are covered too. A game's own routes, and
|
||
an element marked `data-game`, allow that game's words.
|
||
- `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. See D51–D53 — the scripts were green before the phase started; the browser walk and a real brand mount are what found the three defects |
|
||
| **12** | Delivery: two-stage Dockerfile, pull-only `docker-compose.yml` with both bind mounts, `.env.example`, the Gitea Actions workflow that publishes **and deploys** (D54), README, CONTRIBUTING, the community-health files this repo alone lacked, and `DEPLOY.md` (D55, D57). It found the one defect that only a container behind a proxy can find — see D56 |
|
||
|
||
Phases 5 and 6 are deliberately adjacent and early: the beta cannot start without `/privacy`, and
|
||
the closed test is the nearest real deadline.
|
||
|
||
**All twelve are built, as of 2026-08-25.** The `rgcom` runner was registered on the host the next
|
||
day, in host mode as §7 of `DEPLOY.md` requires. What is left is not a phase: point the DNS record at
|
||
the host (§14, N1), and — when the demo VM exists (§15) and the Play track is open — put two URLs
|
||
into the mounted `brand.json`. Neither is a code change, which was the point.
|
||
|
||
**One thing did need a code change.** The merge that landed phase 12 built its image and then could
|
||
not publish it: Cloudflare rejected the largest layer with `413 Payload Too Large`, so the registry
|
||
stayed empty and the deploy never ran. See **D58** — the layer is split, and the workflow now counts
|
||
layers before it pushes.
|
||
|
||
**Revision 4 adds six more** (§16), one pull request each, with a pause for the org lead between
|
||
them:
|
||
|
||
| Phase | Deliverable |
|
||
|---|---|
|
||
| **13** | Facts: `platform.json` per game, `checkFacts.mjs` reading the per-game bundles, the two values already red on `main`, and the absences the second game resolves |
|
||
| **14** | Vocabulary: game-neutral core docs, an **Ultima Online** docs group, redirects for every moved page, and `checkTerms.mjs` (D64) over the docs |
|
||
| **15** | The **Rust** docs group |
|
||
| **16** | **RunicNPC**: its docs subgroup inside Rust and the full API reference with its drift check (D62) |
|
||
| **17** | Marketing: per-game pages, `/runicnpc/`, the homepage, `/features/`, `/modules/`, `/app/`, the architecture drawings, and `checkTerms.mjs` widened to all of `src/` |
|
||
| **18** | Rust screenshots from the rigs (D65) |
|
||
|
||
---
|
||
|
||
## 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. Phase 12 shipped everything needed to point it: the
|
||
`A` record, the proxy requirements and the three Cloudflare features that break a hash-based CSP are
|
||
in `DEPLOY.md` §4. Creating the record is the org lead's, on the day the host is up.
|
||
|
||
**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. The demo instance
|
||
|
||
D12. Recorded on 2026-08-19 so the IA reserved room for it and nothing had to be restructured
|
||
later. **It exists now:** `https://demo.runicgateway.com`, built in `website` and live since
|
||
2026-10-27. Its design of record is `docs/website/DEMO_MODE.md` (DM1-DM29), not this section, and
|
||
it came out different from the plan below in three ways that matter to this site:
|
||
|
||
- **It runs Rust, not Ultima Online.** The site is the normal `website` image with the Rust module,
|
||
linked to the org's Oxide test server. So the `/uo/…` deep links D25 wrote have nothing to open
|
||
there.
|
||
- **It resets every two hours, not hourly, and not by reverting a VM.** A scheduled workflow
|
||
restores the game server from a baseline, recreates the site's database, and seeds it again
|
||
(`DEMO_MODE.md` §7). The banner gives the next reset in UTC.
|
||
- **Its sign-ins are generated on its host.** The passwords exist only in the demo's own `.env`
|
||
and in what the demo publishes: with `DEMO_MODE` on, `GET /api/v1/public/settings` answers
|
||
`demo.logins`, which is what its sign-in page lists.
|
||
|
||
### How the site reaches it (2026-10-28)
|
||
|
||
`DEMO_MODE.md` §10 step 6 says "runicgateway.com `/beta` publishes the logins". The org lead took
|
||
two decisions to do that:
|
||
|
||
**D82 — `/beta` lists the demo's sign-ins as the demo publishes them.** With `demoUrl` mounted,
|
||
`/beta` (already rendered per request) reads the demo's `GET /api/v1/public/settings` on the
|
||
server, caches the answer for a minute, and lists each `demo.logins` row with the next wipe
|
||
time. A copy pasted into `brand.json` was the alternative, and it is one regenerated `.env` away
|
||
from publishing passwords that do not work. The read is this server's, not the visitor's, so D9
|
||
and the CSP are unchanged. It never fails the page: the demo is down for a few minutes at every
|
||
wipe, and then (or after 1.5 seconds without an answer) `/beta` links to the demo's own sign-in
|
||
page instead, and asks again 15 seconds later. Every field is shape- and length-checked before
|
||
it is rendered. Gate two ("somewhere to point it") reads as in place, with the demo's host name
|
||
to type into the app's first screen. `lib/demoLogins.mjs` holds it; `test/demo.test.mjs` tests it.
|
||
|
||
**D83 — a demo deep link names its game, and shows only for the games the demo runs.** A link
|
||
into a game module's pages carries `data-demo-game` after `data-demo-path`. A new mounted list,
|
||
`demoGames` (stock `[]`), names the modules the demo runs, and `applyBrand.mjs` fills a game's
|
||
links only for a game listed there. Core's links (`/wiki`, `/site/events`, `/site/news`) carry no
|
||
game and follow `demoUrl` alone. The Rust features gain links into the demo's server page
|
||
(`/rust`, and `/rust/servers/demo` with its `?tab=` for the feed, leaderboard, online, map,
|
||
wipes and clans). `demo` is the server's id, fixed by the demo's seed fixture. UO's links stay
|
||
in the markup and stay hidden, so a UO demo later is one more entry in the list. `checkBrand.mjs`
|
||
holds `demoGames` to the games in `games/index.mjs` and checks that `DEEP_LINK` reads the game.
|
||
|
||
The `public-demo` entry in `notBuilt.mjs` is gone, because its `resolvedBy` was met.
|
||
|
||
**What the operator mounts:** `"demoUrl": "https://demo.runicgateway.com"` and
|
||
`"demoGames": ["rust"]` in the site's `brand.json`, then a restart. `/beta` follows `demoUrl` on
|
||
the next request; the prerendered pages follow both at the restart.
|
||
|
||
### The plan as written (2026-08-19)
|
||
|
||
**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.
|
||
|
||
---
|
||
|
||
## 16. A second game: Rust, RunicNPC, and a vocabulary rule
|
||
|
||
Revision 4, 2026-10-22. The org lead's brief, in their words: *update the site for the Rust module,
|
||
include RunicNPC documentation, and keep terms like shard or clan — the game-specific terms — in
|
||
that module only, not on the site at large.*
|
||
|
||
### What changed since the site was built
|
||
|
||
All values below were read on 2026-10-22 from `origin/main`, the Gitea API, and the installer's
|
||
`bundles` branch. The site was last fact-checked on 2026-09-15.
|
||
|
||
| | Now | The site says |
|
||
|---|---|---|
|
||
| Module API | **1.17.0** (`website` `main:server/src/modules/version.js`) | 1.10.0, so `checkFacts` is **already red on `main`** |
|
||
| Installer | **v0.8.0** | v0.2.0, also red |
|
||
| `Module-Rust` | **v0.5.0**: id `rust`, `coreApi ^1.17.0`, capabilities `rust servers killfeed leaderboard presence wipes identity map` | nothing |
|
||
| Rust bundle | **`v2/rust/current.json`**, bundle **2026.10.22**, protocol **14**: Rust-Link v0.3.0, Rust-Plugins v0.3.0, Oxide ≥ 2.0.7585, Carbon ≥ 2.0.259, requires Kits + ZoneManager, plus RunicNPC v1.0.0 | nothing |
|
||
| UO bundle | **`v2/servuo/current.json`**. The root `current.json` is the schema-1 manifest, still at 2026.09.15 | read from the root |
|
||
| RunicNPC | **v1.0.0**: an Oxide/Carbon plugin with its own API (`docs/runicnpc/API.md`), `/rnpc` commands, and standalone use without Runic Gateway | nothing |
|
||
| Second module | **Exists and is released**. Core now *refuses* a second game module at boot | "a paper dry-run, deliberately unimplemented" |
|
||
|
||
Two of the §2 absences are therefore now false. **`second-module` is resolved**, and its own
|
||
`resolvedBy` names exactly this event. **`multi-module` needs its wording changed**: it is no longer
|
||
"nothing exercises the columns" but "core refuses to boot a second game module". The
|
||
**Integration Kit's draft banner stays**: the kit still says it is a draft, and its condition is
|
||
someone *outside this project* building a module from it. D8 mirrors the kit and does not
|
||
overrule it.
|
||
|
||
### The vocabulary problem, measured
|
||
|
||
"Shard" appears **227 times in 45 files**. It is not only in the UO material: the whole
|
||
*Getting started* path is a ServUO walkthrough (`connect-a-game-server` alone uses the word 14
|
||
times). The homepage diagram's box reads "ServUO today", and the Reference group has a "Shard event
|
||
catalog". The product never had this problem: core's client contains no "Shard" label at all, and
|
||
"Admin → Shard (uo-link)" is a menu item **module-uo** registers. So the site is less accurate
|
||
than the product here, and the fix is a correction rather than a rebrand.
|
||
|
||
### The six decisions
|
||
|
||
Taken by the org lead on 2026-10-22, each from a concrete example.
|
||
|
||
**D60 — per-game sidebar groups.** *Getting started* becomes game-neutral and ends in "Connect your
|
||
game server", which sends the reader to their game. Two new groups sit after *Reference*:
|
||
|
||
- **Ultima Online** takes everything that is UO today: connecting a ServUO shard, the shard
|
||
connection, client files, `Bridge.cfg`, uo-link's `sidecar.toml`, and the shard event catalog.
|
||
- **Rust** covers installing the bridge (installer, Pterodactyl egg, by hand), connecting the site,
|
||
linking Steam accounts, permissions and groups, mod config from the site, events, zones and
|
||
rewards, the live map, and troubleshooting.
|
||
|
||
*Administration*, *Modules*, *Architecture* and *Reference* keep only core material, described in
|
||
core's own words. Every page that moves gets a redirect from its old URL. The alternative, a
|
||
separate sidebar per game under `/docs/uo/` and `/docs/rust/`, was not taken.
|
||
|
||
**D61 — RunicNPC lives inside Rust and also has its own page.** Its docs are a subgroup of the Rust
|
||
group: overview, install (with and without Runic Gateway), the `/rnpc` commands, managing NPCs from
|
||
the site's profile and placement screens, and the API. Marketing gets a RunicNPC card on the Rust
|
||
module page and a short standalone `/runicnpc/` page. The plugin works on a Rust server that runs no
|
||
Runic Gateway at all, and that server's owner should be able to find it without first learning what
|
||
the platform is.
|
||
|
||
**D62 — the RunicNPC API is carried in full, with a drift check.** This is a **deliberate exception
|
||
to §1**, which links contracts rather than copying them. The site renders every call, every hook it
|
||
raises, and every data shape. A drift check, landing as a section of `checkReference.mjs`, reads
|
||
`docs` `main:runicnpc/API.md` through the `contents` endpoint (§12) and fails if the two disagree: a call, a hook, or a signature that exists on one side
|
||
only, or a signature that differs. It is two-directional like `checkQuickstart.mjs`.
|
||
`docs/runicnpc/API.md` stays normative, and the site page says so at the top. The reason for the
|
||
exception: the API's readers are plugin authors outside this project, and a page they can search and
|
||
read in the site's own docs is the difference between using the plugin and not.
|
||
|
||
**D63 — game names yes, game vocabulary no.** Outside the game sections the site may name the games
|
||
it supports, as product names. It may not use their vocabulary. Concretely:
|
||
|
||
- The homepage diagram's box reads "Game server · opens no inbound port". A **"Supported games:
|
||
Ultima Online · Rust"** strip on the homepage, `/features/` and `/modules/` links to each game's
|
||
page.
|
||
- Words such as *shard, guild, clan, wipe, ServUO, uo-link, Bridge.cfg, cliloc, Oxide, Carbon,
|
||
uMod, rust-link* appear only in the Ultima Online and Rust sections, their per-game marketing
|
||
pages, and data files that belong to one game.
|
||
- Core concepts keep core's names: a **Team** (never a guild or clan), a **game server** (never a
|
||
shard), **the sidecar** and **the bridge plugin** (never uo-link or rust-link), **the game
|
||
module** (never module-uo). Steam is not on the list: Steam sign-in is a core identity provider
|
||
(`website` `server/src/auth/identityHooks.js`), even though Rust is its first user.
|
||
|
||
**D64 — a twelfth check enforces D63, amending D53.** D53 declined a twelfth check because nothing
|
||
then needed one. This does, for the same reason `checkTokens.mjs` exists: a convention that holds
|
||
only while everyone remembers it does not hold. `scripts/checkTerms.mjs` keeps a list of game words,
|
||
each tagged with its game. It fails the build, naming the file, line and word, when one appears
|
||
outside that game's allowed paths. The allowlist is short and justified line by line. Its first
|
||
known entry is Discord's own term: the Discord bot settings really do ask for a *guild ID*, and
|
||
that is Discord's word, not a game's. Phase 14 runs the check over the docs; phase 17 widens it to
|
||
all of `src/` once the marketing pages have somewhere to move their game content.
|
||
|
||
**D65 — Rust screenshots come last, from the rigs.** D4 still holds: real captures only. Every Rust
|
||
page is written first, without screenshot slots and never with placeholders. Phase 18 then brings the
|
||
rigs up to date (Rust, Oxide and Carbon, as the rig rule requires before any rig work), points the
|
||
walk site at them, and captures through `scripts/captureScreens.mjs`, the same pipeline as phase 9
|
||
(D45). Candidates: the server list, the live map, the kill feed, the leaderboard, the permissions
|
||
screen and the NPC profile editor.
|
||
|
||
### One rule the phases need, recorded so review can object
|
||
|
||
Adopted by the org lead on 2026-10-22 as **D67**.
|
||
|
||
**D67 — A screenshot belongs to its game.** Phase 9's UO captures carry the word "Shard" in the pixels and
|
||
in their alt text. Alt text has to describe the image honestly, so a capture tagged with a game in
|
||
`screens.mjs` may use that game's words in its caption and alt. The page showing it must name the
|
||
game next to it. `checkTerms.mjs` reads the tag. The homepage's "What it looks like" therefore either
|
||
shows a core screen, or shows a game screen labelled with its game. Phase 17 picks one and says which.
|
||
|
||
### The phases
|
||
|
||
Each phase is one pull request into `main`, with a pause for the org lead before the next.
|
||
|
||
**Phase 13 — facts.** `platform.json` grows a `games` object, with `uo` and `rust` each holding
|
||
their bundle tag, protocol, component pins, framework minimums, module release and capability list,
|
||
and a `runicnpc` entry holding its release. `checkFacts.mjs` reads `v2/servuo/current.json` and
|
||
`v2/rust/current.json` instead of the root manifest. It re-reads `Module-Rust` `main:module.json`
|
||
the same way it reads module-uo's, and checks the RunicNPC release. Module API and installer go to
|
||
their current values, and every page quoting them is re-read (§1). `notBuilt.mjs` drops
|
||
`second-module` and rewords `multi-module`. `capabilities.mjs`'s coverage assertion gains its Rust
|
||
half, so a capability Module-Rust adds turns the site red the same way a UO one does. **This is the
|
||
phase that turns `main` green again.**
|
||
|
||
**Phase 14 — vocabulary and the Ultima Online group.** Getting started is rewritten game-neutral,
|
||
and its "connect" step becomes a short page that hands off to each game. The UO pages move into the
|
||
new group, each with a redirect from its old URL. Every remaining core page is swept to core's
|
||
words. `checkSidebar.mjs`'s `plannedSidebar` gains the new group. `checkTerms.mjs` lands, scoped to
|
||
`src/content/docs` and `src/config/sidebar.mjs`. `checkReference.mjs`'s entries for `Bridge.cfg`,
|
||
`sidecar.toml` and the event catalog follow their pages.
|
||
|
||
**Phase 15 — the Rust group.** Journey pages written from `docs/rust-link/INSTALL.md`,
|
||
`INTEGRATION.md`, `docs/modules/rust/PLAN.md` and its redesign and fix plans, and `OPERATING.md`.
|
||
They teach and link out (§1). The Rust reference pages (rust-link's `sidecar.toml`, the bridge
|
||
plugin's config, and the protocol-14 event list) are enumerated and checked against their sources
|
||
the way D39 does for UO, extending `checkReference.mjs` rather than adding a check. Every admin
|
||
screen described is walked on the walk site first (D36).
|
||
|
||
**Phase 16 — RunicNPC.** The subgroup: overview, install, commands, managing NPCs from the site, and
|
||
the API reference (D62), with its drift check. That check is the one D62 adds; the count stays at
|
||
twelve because it lands as a section of `checkReference.mjs`, which already reads `docs` through
|
||
`contents`.
|
||
|
||
**Phase 17 — marketing.** `/modules/uo/` and `/modules/rust/` carry each game's own feature list,
|
||
written in that game's words. `/runicnpc/` is the standalone page (D61). `/modules/` gains Rust
|
||
beside UO. `/features/`'s module-supplied group becomes "what a game module adds", linking to both.
|
||
The homepage diagram and caption, `/architecture/`'s two drawings, `/app/` (the Android app has
|
||
Rust screens too), `/community/`'s "there is one module" passage and the supported-games strip
|
||
(D63) are updated. `collection.mjs` and the Play Data Safety notes are re-read against what the Rust
|
||
module collects, Steam ids first, before anything is claimed there. `checkTerms.mjs` widens to all of
|
||
`src/`.
|
||
|
||
**Phase 18 — screenshots** (D65), plus the emulator pass for `/app/`'s Rust screens. This is the only
|
||
phase that needs the rigs.
|
||
|
||
### How phase 13 built it
|
||
|
||
One decision, **D66**, taken 2026-10-22 when the restructure reached the homepage. The count of
|
||
record is now **sixty-six**.
|
||
|
||
**D66 — the hero and the footer show the Module API only.** Both used to read "Protocol 8 · Bundle
|
||
2026.09.15", which was a description of the platform while there was one game. There are now two
|
||
protocols and two bundles, and neither belongs on the front door. The hero and footer show **Module
|
||
API**, the one version every deployment shares. The structured data's `softwareVersion` reads the
|
||
same value. Each game's protocol and bundle are on that game's pages, and the docs landing page
|
||
splits its table in two: shared versions, then one column per game. The alternative was per-game
|
||
chips on the hero ("Ultima Online: protocol 8, bundle … · Rust: protocol 14, bundle …"). It was
|
||
declined as busier and as putting game facts on the front door.
|
||
|
||
**`platform.json` is per game.** `protocol`, `bundle` and `moduleUoCapabilities` are gone. In their
|
||
place is a `games` object (`uo` and `rust`, each with its protocol, its sidecar and plugin repos, its
|
||
module's capabilities and `coreApi`, and its bundle pins), plus a `runicnpc` block for the plugin's
|
||
API version, framework floors and required plugins. `releases` stays flat, so every repository's
|
||
release is read the same way. **41 facts** are now checked, up from 19. Checks 1, 2, 4 and 5 loop
|
||
over the games, and the bundle reads `v2/<game>/current.json` and refuses anything but schema 2.
|
||
|
||
**What the re-read found** (§1 obliges a re-read of every page that quotes a changed value):
|
||
|
||
- **Three hard-coded Module API versions in prose**, all saying 1.6.0, on *The module API* and *The
|
||
module manifest*. §12 has forbidden that since phase 1. Nothing caught it because `checkFacts`
|
||
guards `platform.json` and not the prose. All three now read `platform.json`. The manifest page's
|
||
worked example of a caret range now quotes the two modules' real `coreApi` ranges, which are
|
||
checked facts as of this phase.
|
||
- ***Module lifecycle* said modules load alphabetically.** That stopped being the whole truth on
|
||
2026-09-23, when the installer began refusing a second module, and stopped being true at all on
|
||
2026-10-22, when the loader began refusing one (W19). The page now says what the loader does. *Installing
|
||
modules* and *Managing modules* had never mentioned the one-module rule at all, and now do.
|
||
- **"There is one module" was false in five places.** Those were the `/architecture/` seam drawing and its
|
||
"what this does not yet prove", `/community/`, the `/features/` lede, the Game intelligence
|
||
summary, and the homepage's list of absences. Each now says two modules exist, both built inside
|
||
this project, and that the Integration Kit keeps its draft banner until someone outside does it.
|
||
- **The homepage diagram's sub-labels** said "ServUO today" and "uo-link". They became false the day
|
||
Rust shipped, so they changed here rather than waiting for phase 17. They now carry D63's wording.
|
||
The caption names both games.
|
||
|
||
**One planned item moved to phase 17.** §16 put the Rust half of `capabilities.mjs`'s *page* coverage
|
||
assertion in this phase. But a coverage assertion needs a list of Rust capabilities on a page to
|
||
cover, and phase 17 is the phase that writes that list. Phase 13 does the half that has something to
|
||
check: `checkFacts` now holds Module-Rust's declared capabilities against `platform.json`, so a
|
||
capability the module adds turns this repo red today. The page-side assertion lands with the page.
|
||
|
||
### How phase 14 built it
|
||
|
||
Three decisions, taken on 2026-10-22 when the restructure reached pages D60 had not placed. The
|
||
count of record is now **sixty-nine**. D67 is the screenshot rule above, adopted that day.
|
||
|
||
**D68 — a game's real example on a core page is a tagged, labelled block.** Some core pages need
|
||
a real example rather than an invented one: *The module manifest* shows a manifest, *Scheduled
|
||
events* names a module's budget dimensions, and the *Installer CLI* has to print
|
||
`--game <servuo|rust>`, because that is the flag. §1 prefers the real thing, since an invented
|
||
example is checked by nothing. So the example keeps its game's words and says whose they are:
|
||
`src/components/GameExample.astro` prints the game's name above the block, read from
|
||
`platform.json` so the label cannot disagree with the attribute, and `checkTerms.mjs` allows that
|
||
game's words, and only that game's, between the tags. It is D67's bargain applied to text: a
|
||
game's words are honest on a core page when the game is named right beside them. The
|
||
alternatives were an invented neutral example (the Integration Kit's template could not serve: it
|
||
mounts `/clans`) and prose only, which would have left the core pages less concrete.
|
||
|
||
**D69 — *Verify the whole stack* stays in Getting started, game-neutral.** This amends D60, which
|
||
had Getting started end on the hand-off page. The core page now teaches the four links in the
|
||
chain — the plugin loaded, the game server reached the sidecar, the sidecar is healthy, the site
|
||
is ingesting — which are the same for both games, and sends the reader to their game for the
|
||
commands. The Ultima Online commands became that group's *Verify the bridge*. The Ultima Online
|
||
host requirements moved onto *Connect a ServUO shard*, and the Ultima Online rows of
|
||
*Troubleshooting* became the group's own *Troubleshooting* page.
|
||
|
||
**What moved, and what stayed.** The Ultima Online group has nine pages: an overview (*The Ultima
|
||
Online module*, which took *Install a game module*'s "what the `uo` module adds"), *Connect a
|
||
ServUO shard*, *Verify the bridge*, *The shard connection*, *Client files*, *Troubleshooting*,
|
||
*Bridge.cfg*, *sidecar.toml* and the *Shard event catalog*. Five pages changed URL, and each old
|
||
URL answers with a real `301` from the server: `src/config/redirects.mjs`, asserted by
|
||
`test/headers.test.mjs`. *Connect a game server* did **not** move. Its URL is now the
|
||
game-neutral hand-off page (*Connect your game server*), which is a better landing for an old link
|
||
than a redirect into one game. Until phase 15, its Rust row links to `docs`
|
||
`main:rust-link/INSTALL.md`.
|
||
|
||
**The core pages use core's words** (D63): a Team, a game server, the sidecar, the bridge plugin,
|
||
the game module. Detail that was really one game's moved into that game's pages rather than being
|
||
dropped. *The bridge*'s guild-roster passage, for example, is now a general rule about Team rosters,
|
||
with the Ultima Online specifics in its event catalog. Three passages were checked against Rust before they were
|
||
generalised: both bridges dial loopback newline-delimited JSON with one writer thread, both
|
||
sidecars answer `/health` with *plugin connected*, both plugins ship `EventsEnabled` off, and
|
||
both modules encrypt their sidecar token with core's key. `canonicalDocs` is now grouped by
|
||
game, and the Rust group carries four documents. Discord's *guild* became *Discord server*
|
||
everywhere except the one label core's Discord Bot screen really prints, *Guild (server) ID*,
|
||
which is the check's only allowlist entry, as D64 predicted.
|
||
|
||
**`checkTerms.mjs` is the twelfth check.** It reads every docs page, frontmatter included, and
|
||
both sidebar trees as data. It knows 16 words, each tagged with its game. Link targets, bare URLs,
|
||
imports and `{…}` expressions are not read, because an address is not prose and an expression
|
||
renders a value from `src/data/`, which phase 17 brings into scope. Fenced code is read. An
|
||
allowlist entry that stops matching fails the check, so the list cannot rot. Before it was
|
||
trusted it was run against a planted page: five violations (a word in prose, in a title, in link
|
||
text, in a code fence, and inside another game's block) all failed, and a URL and an expression
|
||
carrying the same words both passed.
|
||
|
||
**Two findings outside this repository**, reported rather than fixed (they belong to other
|
||
repositories):
|
||
|
||
- **Core's own Teams panel says "shard".** `website` `client/src/lib/teamAdmin.js` tells an
|
||
administrator the list "is not a confirmed empty shard". The docs page now paraphrases it
|
||
rather than quoting it.
|
||
- **The installer's `--help` says "Which game's shard side"** for `--game`, and `--site-url` names
|
||
"Admin → Shard (uo-link)" for both games.
|
||
|
||
### How phase 15 built it
|
||
|
||
Two decisions, taken by the org lead on 2026-10-22 before any page was written. The count of
|
||
record is now **seventy-one**.
|
||
|
||
**D70 — the Rust group is fifteen pages.** D60 named the topics and not the pages. The group
|
||
follows the Ultima Online group's shape: an overview, then the journey (*Install the bridge*,
|
||
*Connect the site*, *Verify the bridge*), then one page per admin screen where there is one
|
||
(*Permissions and groups*, *Mod config from the site*, *Who sees who is online*, *Events, zones
|
||
and rewards*), then *Linking Steam accounts*, *Optional plugins*, *The live map* and
|
||
*Troubleshooting*, and three reference pages (*sidecar.toml*, *RunicGateway.json*, *Event
|
||
kinds*). *Who sees who is online* and *Optional plugins* got pages of their own rather than
|
||
being folded into the overview and the events page, because each has a screen or a trap of its
|
||
own — RaidableBases must be restarted, never reloaded. The alternative was ten longer pages. The
|
||
question put to the org lead called this option "14 pages" while listing fifteen; the list is
|
||
what was chosen. RunicNPC's subgroup is phase 16's.
|
||
|
||
**D71 — the Rust event kinds are checked against the module's classification.** The plan said the
|
||
protocol-14 event list would be checked "against its sources". There were two candidates:
|
||
`PROTOCOL.md`, whose kinds are spread across tables from §8.4 to §20.12, and `Module-Rust`
|
||
`server/catalogue.js`, which holds three lists in code — public, staff, and the presence subset
|
||
of public. The second was chosen, for D39's reason: the visibility ladder is checked against the
|
||
module that enforces it, and so is this. The module's own test already holds its lists to
|
||
`PROTOCOL.md`, so the chain reaches the spec without this repo parsing prose tables. The page
|
||
shows three classes, and each is compared separately, so a kind moving from staff to public
|
||
upstream fails here twice.
|
||
|
||
**`checkReference.mjs` gained three Rust sections**, as the plan said, rather than a new check:
|
||
rust-link's `sidecar.toml` (parsed from its serde structs by the same function as uo-link's, now
|
||
shared) and the six `RUSTLINK_…` variables that override it; the bridge plugin's
|
||
`RunicGateway.json`, read from the public fields of `PluginConfig` in `RunicGateway.cs`; and the
|
||
event kinds (D71). The canonical documents gained `OPERATING.md` and `CARBON.md` under Rust.
|
||
|
||
**Every admin screen was walked on the walk site first** (D36), signed in as the walk admin:
|
||
Rust servers (Test, Edit, Chat titles, Optional mods), Rust permissions (a player, a group's four
|
||
tabs), Rust mod config (the tree and `RunicGateway.json`'s read-only keys), Rust visibility, Rust
|
||
zone presets, the event editor's action list, a user's Rust section, and the public server page's
|
||
feed, map and leaderboard. Nothing was saved. The NPC screens are phase 16's. Writing from the
|
||
walk and the source corrected three of my own drafts before they shipped: a reward does **not**
|
||
need a linked account (rewards go to Steam ids), the bundle ships **three** helpers, not two, and
|
||
the Test button prints the raw status (`protocol-mismatch`), not a phrase.
|
||
|
||
**What the re-read found in this repository**, fixed here:
|
||
|
||
- ***Verify the bridge* (Ultima Online) showed `"protocol":4`** in its `/health` sample, a
|
||
hard-coded version four protocols stale. Both games' samples now render the protocol from
|
||
`platform.json` through Starlight's `<Code>`, which is how a version gets into a code block
|
||
without being typed.
|
||
- ***Authentication* said "four tabs"** and never mentioned Steam, which core shipped with Module
|
||
API 1.17.0. It now has five, and a *Steam* section the Rust pages link to.
|
||
- ***Scheduled events* and *Events architecture* said the site cannot reach `EventsEnabled`.**
|
||
True for Ultima Online, false for Rust: an administrator can edit `RunicGateway.json`, that switch
|
||
included, from Admin → Rust mod config. Both pages now say so, the first as two tagged examples
|
||
(D68).
|
||
|
||
**`checkFacts` went red mid-phase, as designed.** Installer v0.8.1 shipped on 2026-10-22 — the
|
||
game-neutral `--help` that phase 14 reported, fixed in `installer` #43 alongside `website` #221 for
|
||
the Teams panel — so `platform.json` moved to it. Its only change is that help text, which no page
|
||
quotes.
|
||
|
||
**Findings in other repositories.** Reported in the PR, and then fixed at the org lead's ask the
|
||
same day, each in its own repository's PR into `edge`:
|
||
|
||
- **`Module-Rust` admin copy showed decision numbers.** A group's *Servers* tab read "unless it is
|
||
shared (D189)", and the *not imported yet* chip's tooltip ended "(D198)". A search of every
|
||
non-comment line found **24** such strings an administrator reads — the event editor's step and
|
||
budget descriptions and five NPC validation errors among them. All went in `Module-Rust` #59; the
|
||
developer-facing swagger descriptions keep theirs.
|
||
- **`Module-Rust`'s servers page read "Last pushed: 1 player hold one."** Also `Module-Rust` #59.
|
||
- **`Rust-Plugins`' `PluginConfig.EventsEnabled` comment said it "gates taking a lease only"**,
|
||
which stopped being true at protocol 9. It now lists everything the switch gates, read from every
|
||
check in the file: `Rust-Plugins` #45.
|
||
- **`docs` `rust-link/INTEGRATION.md` predates the installer**: it says there is no installer
|
||
support for Rust and shows protocol-1 samples. **`rust-link/INSTALL.md`'s by-hand step names two
|
||
helpers**; the bundle ships three. The org lead approved a separate `docs` PR for these:
|
||
`docs` #370.
|
||
|
||
### How phase 16 built it
|
||
|
||
Three decisions, taken by the org lead on 2026-10-22 before any page was written. The count of
|
||
record is now **seventy-four**.
|
||
|
||
**D72 — the RunicNPC subgroup is eight pages, one of them a shared profile-field reference.** D61
|
||
named five topics. The subgroup sits at the end of the Rust group, nested, at
|
||
`/docs/rust/runicnpc/`: *RunicNPC* (the overview), *Install RunicNPC*, *Commands*, *NPC profiles*,
|
||
*NPC placements*, *Cost and caps*, *Profile fields* and *The API*. The site's two NPC screens get a
|
||
page each, as the Rust group's screens do, and the cost warning and caps get their own page rather
|
||
than a section of *Install*. ***Profile fields* is the one page every way of writing a profile
|
||
links to**: the site's editor (whose sections it follows, with each field's label), `rnpc.profile
|
||
set` (which takes the same JSON paths), the data file, and the API. So the API page shows calls,
|
||
hooks, placements and routes, and links there for a profile's shape rather than repeating it. The
|
||
alternatives were D61's five pages, and seven without the shared reference.
|
||
|
||
**D73 — the API check compares signatures and field names.** D62 named calls, hooks and
|
||
signatures. The check also compares the field names of every data shape `API.md` gives: a
|
||
profile's fields, `RunicNpc_List`'s keys, a placement's keys, a route's keys, and the owners. A
|
||
field added to the profile upstream turns this site red the same way a new call does. The
|
||
alternative, calls and hooks only, would have let the largest and most-changed shape drift
|
||
unnoticed.
|
||
|
||
**D74 — the site's API page carries the current API only.** `API.md` explains itself with decision
|
||
numbers (*D258, D269*) and plan stages, and tags each call with the API version that added it. The
|
||
site page drops both. Decision numbers mean nothing to a plugin author outside this project — the
|
||
reason `Module-Rust` #59 removed them from the admin screens — and the version history is
|
||
`API.md`'s to keep. The page states the current API version, read from `platform.json`, and shows
|
||
how to check it before calling. The alternatives were keeping the "since API n" tags, and copying
|
||
`API.md` faithfully, decision numbers included.
|
||
|
||
**The drift check is two sections of `checkReference.mjs`**, so the count of checks stays at
|
||
twelve, as the plan said:
|
||
|
||
- **Against `docs` `main:runicnpc/API.md`**, through the `contents` endpoint: the API version
|
||
against `platform.json`; all 24 calls by whole signature, parsed from their headings; all 10
|
||
hooks by signature, from their table; and the field names of the five shapes (D73). A profile
|
||
field cell that names two fields (`hurtByPlayers`, `hurtsPlayers`), or a bare name after a dotted
|
||
one (`phases[].damageDealt`, `aimCone`), is read the way a person reads it. And the page itself:
|
||
every call must have exactly one `<Signature call="…" />` on `api.mdx`, so "carried in full" is
|
||
held by the build, not by whoever last edited the page.
|
||
- **Against `runicnpc-rust` `main:plugin/RunicNPC.cs`**: the console verbs it registers, the
|
||
permissions it registers (and that every command's permission is one of them), the `key=`
|
||
options `ParseOptions` accepts, and the JSON keys of its config classes. These are names on the
|
||
*Commands* and *Cost and caps* pages, and §12 has checked every name the Reference pages write
|
||
since phase 8. D62 did not ask for this half; it is the phase-15 precedent (the bridge plugin's
|
||
`RunicGateway.json` is read from its source the same way) applied to the two new pages that list
|
||
names.
|
||
|
||
Signatures print from `src/data/runicnpc.mjs` through `src/components/Signature.astro`, which
|
||
renders them with Starlight's `<Code>`, so the signature a reader copies is the one the check
|
||
compared. Descriptions live beside them as short strings, written in the site's words, and
|
||
`src/components/Ticks.astro` renders their backticks, bold and links — never HTML. Before the check
|
||
was trusted, six drifts were planted: a parameter added to a call, a hook renamed, a `RunicNpc_List`
|
||
key renamed, a profile field renamed, a command verb renamed, and one call's `<Signature>` removed
|
||
from the page. All six failed, each naming the side it broke.
|
||
|
||
**The sidebar check learned nested groups.** `checkSidebar.mjs` and `checkTerms.mjs` read both
|
||
trees through `flatLabels()` in `sidebar.mjs`, which writes a nested page as `RunicNPC › Commands`.
|
||
The plan's order, and its vocabulary, are held for a subgroup exactly as for a group.
|
||
|
||
**Every admin screen was walked first** (D36), signed in as the walk admin on the walk site against
|
||
both rigs: *Rust NPC profiles* (the servers with their RunicNPC version, API and last push; the
|
||
profile list; the faction table; the editor opened on a new profile, through every section) and
|
||
*Rust NPC placements* (the server picker, the map with its layers, a waiting placement whose
|
||
profile had gone, and the cost warning above the list). Nothing was saved. Two claims were then
|
||
checked in `Module-Rust`'s server before they were written: the push tick is 30 s with a
|
||
quarter-hourly audit (`npcSync.js`), and saving a profile asks every covered server that answers
|
||
for its kits.
|
||
|
||
**What else changed.** The Rust overview's staff table links the two NPC screens to their pages,
|
||
and its "In this group" table gains the subgroup. *Events, zones and rewards* links its *Place
|
||
NPCs* step to the profiles page. The canonical documents gain `runicnpc/API.md`, `COMMANDS.md`,
|
||
`INSTALL.md` and `PLAN.md` under Rust, and that page now names the API page as the one deliberate
|
||
exception to "every page links out".
|
||
|
||
**Two findings outside this repository**, reported in the PR:
|
||
|
||
- **The Rust bundle's Oxide floor is lower than RunicNPC's — and that is intended.**
|
||
`v2/rust/current.json` says Oxide 2.0.7585, the bridge plugin's floor, while RunicNPC's own
|
||
`plugin.toml` says 2.0.7726. The org lead ruled on 2026-10-22 that **2.0.7585 is the original
|
||
compatible version and stays**. It moves only when the CI staging drill reports an issue, and then
|
||
as part of the update that fixes it. The site states each number where it belongs (the Rust
|
||
overview quotes the bundle's, the RunicNPC pages RunicNPC's) and claims nothing about the gap.
|
||
- **`API.md`'s profile table broke in two places.** The `boss` rows and the `loot` rows each
|
||
followed a blank line with no header row, so Gitea rendered them as plain text full of pipes.
|
||
The check parses them line by line and was not affected. Fixed at the org lead's ask in `docs`
|
||
#371, which removes the two blank lines and changes nothing else.
|
||
|
||
### How phase 17 built it
|
||
|
||
Five decisions, taken by the org lead on 2026-10-22 before any page was written, each from a
|
||
concrete example. The count of record is now **seventy-nine**.
|
||
|
||
**D75 — core marketing pages describe what a game module adds in core's words, and link to
|
||
the games.** The homepage, `/features/`, `/architecture/` and `/app/` name both games and use
|
||
neither game's vocabulary. The module-supplied group became *What a game module adds*: seven
|
||
things both modules supply, each in its own way (live server status, a live feed, leaderboards,
|
||
maps, Teams from the game, players' own game accounts, game steps for events), with a button
|
||
to each game's page. Each game's real list, in its own words, lives in `src/data/games/` and
|
||
renders only on `/modules/uo/` and `/modules/rust/`. The alternative was D68 applied to
|
||
marketing — game-labelled blocks carrying each game's real list on the core pages. It was
|
||
declined as longer, and as the front door carrying two games' jargon at once.
|
||
|
||
**D76 — the homepage's "What it looks like" keeps its game screenshot, labelled.** This is
|
||
D67's choice, made. The capture stays the Ultima Online live-status screen, now printing
|
||
"Ultima Online" beside it, because the claim under the data-path diagram is live game state
|
||
on a public page and no core screen shows that. Phase 18 adds a labelled Rust capture beside
|
||
it. The alternative, a core screen such as the admin dashboard, was declined.
|
||
|
||
**D77 — `/app/` describes the app's Rust screens.** When the phase began they were on
|
||
`Android-app` `edge` only, 26 commits ahead of `main` and in no release. The org lead chose to
|
||
describe them and release them the same day, rather than hold the page or say "next release".
|
||
`/app/`'s game section is now *The game, live*: one item per game, titled with the game's name
|
||
and written in core's words, plus Rust's half of *Your own side of the game*.
|
||
|
||
**D78 — `/runicnpc/` is in the header.** The top navigation is now five items, so a Rust
|
||
server owner who runs no Runic Gateway finds the plugin from any page (D61's reason). It is in
|
||
the footer's Product column and on the Rust module page's RunicNPC card too. The alternative
|
||
was the footer and the card only.
|
||
|
||
**D79 — `checkTerms.mjs` reads the built pages.** D64 had the check widen to all of `src/`. A
|
||
source scan of the marketing pages would see expressions rather than the words they render —
|
||
a capability label, a screenshot caption, a value from `platform.json` — and would take ids
|
||
like `shard-status` and slugs like `guilds` for prose. So the check gained a second pass,
|
||
`--built`, run after the build the way `checkLinks.mjs` is. It reads every page's visible text
|
||
plus `alt`, `title`, `aria-label`, `placeholder`, the `<title>` and the meta description, and
|
||
not link targets, scripts, styles or comments. A game's routes (`GAME_ROUTES`: its docs group,
|
||
its module page, and `/runicnpc/` for Rust) allow its words, and so does any element marked
|
||
`data-game` — `<GameExample>` and, new this phase, a game-tagged `<Screenshot>`. Starlight's
|
||
sidebar and the previous/next links are skipped, because the source pass reads the sidebar as
|
||
data and each neighbour's title is checked on its own page. The source pass is unchanged and
|
||
still runs first, naming a file and a line. Failures from the built pass name a route and a
|
||
snippet. It is still the twelfth check: one script, one word list, one allowlist (whose entry
|
||
now carries a `route` as well as a `file`). The alternative was a source scan with per-file
|
||
exemptions.
|
||
|
||
**The check was proven before it was trusted.** Ten plants went into the built HTML. Six
|
||
failed, each naming where: a word in a core page's text, in an `alt`, in a `title`, a
|
||
UO word inside a block marked Rust, a word split across two syntax-highlighting `<span>`s, and a
|
||
UO word on a Rust page. Four passed: a word only in an `href`, one in a `<script>`, UO words on
|
||
`/modules/uo/`, and a UO block on a core page. On the build as it stood before this phase, the
|
||
pass found 49 words on seven marketing pages; all are gone.
|
||
|
||
**What was built.**
|
||
|
||
- **`/modules/`** is core now. Its Ultima Online worked example became a card per game, from
|
||
`src/data/games/index.mjs`, and what the modules share — a sidecar, their own tables, their
|
||
own releases — is said once beneath them.
|
||
- **`/modules/uo/`** carries the Ultima Online list that used to be *Game intelligence*, the
|
||
three pieces with their versions, and the five screenshots `/features/` showed until now.
|
||
- **`/modules/rust/`** carries eight features for players and four for staff, written from the
|
||
Rust docs group phase 15 walked, the four pieces (the module, rust-link, `RunicGateway.cs`,
|
||
RunicNPC) and the RunicNPC card D61 asked for. It has no screenshots and no slots for them
|
||
(D65, D4).
|
||
- **`/runicnpc/`** is written for a server owner with no site: what it does, the two ways to run
|
||
it, and where to get it. Every number is read — release, API version and floors from
|
||
`platform.json`, the counts of calls and hooks from `runicnpc.mjs`, which phase 16's check
|
||
holds to `API.md`.
|
||
- **The page-side capability check is per game**, closing the item phase 13 moved here.
|
||
`assertGameCoverage()` holds each game's list to its module's declared capabilities in both
|
||
directions. The homepage, `/features/` and each game page call it, and
|
||
`assertDetailCoverage()` now reads the games' lists too.
|
||
- **The supported-games strip** (D63) is `src/components/SupportedGames.astro`, on the homepage,
|
||
`/features/` and `/modules/`.
|
||
- **D67 is in the components.** `screens.mjs` entries may carry `game`; nine are tagged `uo`.
|
||
`Screenshot.astro` and `/app/`'s phone grid print the game's name beside each tagged capture
|
||
and mark it `data-game`.
|
||
|
||
**What the re-read found**, fixed here:
|
||
|
||
- **`/architecture/` said two machines is "the minimum and also the maximum"**, false since Rust
|
||
shipped: one sidecar serves one server, so a community with six servers runs six. The caption
|
||
now says two is the minimum and a sidecar sits beside each game server, and its game host
|
||
section no longer says the installer prints "four values" (that count was one game's).
|
||
- **`/integrations/` and the Discord capability called a Discord server a "guild"** — Discord's
|
||
API word, but read on this site as a game's. They say *Discord server* now, as phase 14 made
|
||
the docs say.
|
||
- **`collection.mjs` was re-read against what the Rust module and Steam sign-in keep**, as the
|
||
plan required, before anything was claimed. *Account records* now names linked sign-in
|
||
identities, Steam's display name and avatar address included (`user_identities`). *Game data
|
||
from the connected server* names, for Rust, each player's Steam id and in-game name, their
|
||
statistics, who is online, and the event record, which includes chat, player reports and the
|
||
IP addresses on sign-in attempts and bans, readable by staff only. Its retention now states
|
||
the module's 30 days for that record and the sidecar's 14-day default. The Play Data Safety
|
||
answers did not change: the app's Rust screens read from the deployment and send it only a
|
||
one-time link code, which the existing "everything you read and post" row already covers. Its
|
||
wording lost "shard", so `PLAY_DATA_SAFETY.md` was regenerated.
|
||
|
||
### How phase 18 built it
|
||
|
||
Two decisions, taken by the org lead on 2026-10-22 while the phase was under way. The count of
|
||
record is now **eighty-one**.
|
||
|
||
**D80 — a name in a screenshot is a role.** Every username a capture shows — a site account, a
|
||
player in a feed, a member of a group — is `admin`, `moderator` or `player`, numbered
|
||
(`player2` …) when one screen needs several. A name that says what the account is reads as a
|
||
demonstration; an invented person's name reads as somebody's real community. It holds from
|
||
this phase on: the phase 9 captures keep their cast, by the org lead's ruling ("no going back
|
||
to change the old ones").
|
||
|
||
**D81 — the Rust captures' players are mock data.** The rig has no players. The phase first
|
||
staged stand-in players on it with a rig-only plugin, and the org lead stopped that in favour
|
||
of writing the game data straight into the demo's database. This amends D4 for the Rust
|
||
captures only. What is real in them is still most of the screen: a real Oxide test server
|
||
behind a real rust-link sidecar, its status, its world map and markers, the permission groups
|
||
its plugins hold, and RunicNPC's sync state. What is mock is the players: the feed, the
|
||
leaderboard, the weapon kills, the server list's player count and the linked Steam account.
|
||
`scripts/mockRustDemo.mjs` writes them as SQL in the module's own row shapes, and every name
|
||
in them is a role (D80). The captions describe what a screen does and never claim a player did
|
||
something; `screens.mjs`, `/modules/rust/` and `/app/` say plainly which part is a demo cast.
|
||
The homepage's Rust capture (D76) is the live map, because nothing in that frame is mocked.
|
||
The alternative, staging real kills with stand-ins, was abandoned after it cost a morning:
|
||
the Rust update had left the rig rebuilding its navmesh for ten minutes, and it stopped
|
||
answering the console for most of that time.
|
||
|
||
**The demo is a second deployment.** One deployment runs one game module (the core refuses a
|
||
second at boot), so the Rust demo has its own database, `rgdemorust`, its own core on the
|
||
review machine, and Module-Rust v0.5.0. The recipe, in order:
|
||
|
||
1. Bring the rig up to date (Rust, Oxide and Carbon, the rig rule). The org lead ruled one rig
|
||
enough: the Oxide rig, now Rust 26139947 and Oxide 2.0.7839. A restart did not update Rust:
|
||
steamcmd cancelled the delta on a DLL Oxide patches ("Corrupt game files"), and a panel
|
||
reinstall did not help either. Removing `steamapps/appmanifest_258550.acf` and starting
|
||
made steamcmd verify everything and finish. The Carbon rig was left on 25902425.
|
||
2. Create the database (without a `COLLATE` clause) and start the core against it with
|
||
`MODULES_DIR` holding the module and `BRAND_NAME="Runic Gateway Demo"`. Create `admin`,
|
||
`moderator` and `player` with generated passwords.
|
||
3. `node scripts/seedDemo.mjs --game rust`, with `RG_RUST_SERVERS` naming a JSON file outside
|
||
the repository that holds the server's id, name, sidecar address and token. It writes the
|
||
neutral identity, the two accounts, the server row with a weekly wipe, public visibility
|
||
for who is online and the map's players (the demo's choice, said in the seed), two RunicNPC
|
||
profiles on a kit the rig's Kits holds, and a VIP group with `player` in it.
|
||
4. `node scripts/mockRustDemo.mjs`, piped into the database, after the site has seen the
|
||
server once so its current wipe exists.
|
||
5. `npm run screens:capture` with the Rust ids. Set the server's player count just before
|
||
`rust-servers`, since the live server writes its own every thirty seconds.
|
||
|
||
**What was built.**
|
||
|
||
- **Nine captures**, all tagged `rust`: six web (the server list, the feed, the leaderboard,
|
||
the live map, the permissions screen with a group open, and the NPC profile editor) and three
|
||
phone (a server's feed, its map, and the player's Rust account). The phone ones are from an
|
||
`Android-app` `edge` debug build on the `s22_ultra` AVD, signed in as `player`; the Rust
|
||
screens were not in a release yet. The app's server list was left out: one server card on a
|
||
phone-height screen is mostly empty.
|
||
- **`captureScreens.mjs` can click.** An entry may carry `click`, the visible text of what to
|
||
click before the shot, for a subject behind a tab or a button the route cannot reach — the
|
||
permissions group and the profile editor. A step that matches nothing fails the shot.
|
||
- **Where they show:** `/modules/rust/` (the public four under what players see, the two staff
|
||
screens under theirs, with a sentence saying which part is a demo cast); the homepage's
|
||
*What it looks like*, the live map beside the Ultima Online capture (D76); `/app/`'s grid,
|
||
whose lede no longer says "not mocked up"; and three docs pages — the live map, permissions
|
||
and groups, and RunicNPC's NPC profiles.
|
||
|
||
**What the phase found**, reported, not fixed here:
|
||
|
||
- **The feed prints prefab names as weapons**: "with ak47u entity", "with bolt rifle entity".
|
||
The bridge sends `weapon` as the held entity's short prefab name, and Module-Rust's feed
|
||
prints it through `format.js`'s `prefab()`, which only turns dots and underscores into
|
||
spaces; the app does the same. It is in the captures as
|
||
the product shows it.
|
||
- **The app's first-run and sign-in screens say "shard"** ("Connect to your shard", "Sign in
|
||
with your shard account"), on every deployment, a Rust one included.
|
||
- **The permissions screen's Players tab lists Steam ids the rig already held**, among them
|
||
the org lead's own account from the player walk. That is the screen working (D31's reversal
|
||
adopts every grant), and why the capture opens the Groups tab; it is noted because any
|
||
demo pointed at a rig shows them.
|
||
|
||
---
|
||
|
||
[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
|