Four documents still describe the pre-module-system website. None of them misconfigures anything, which is why they survived; all four mislead a reader trying to understand how the system is actually put together. ARCHITECTURE.md placed shardIngest.js and uoLinkClient.js inside the website backend. Both live in module-uo/server/utils/ - verified, they are not in website/server/src at all. The document claimed to be "the canonical copy of the diagram; the same diagram is embedded in the website's README", and the two had silently diverged: the live README's diagram has the module subgraph, the loader, and the game behind the module, and this one did not. The diagram is now the live one verbatim, the surrounding prose attributes the shard integration to the module, and the intro no longer frames core as game-aware. The SSE bullet gains the distinction the live README makes: the module declares which kinds are public, core enforces the split. website-README.md had drifted from the live README by 28 lines, all of them the "Three ways in, and none of them is a build" section - the admin panel, the MODULES environment variable, and by hand - which is now the primary module-install story. Re-synced verbatim, since a faithful snapshot is the file's whole purpose. The diff was purely additive; the snapshot contained nothing the live README had dropped. README.md's index was missing thirteen documents, not the four the audit had found: TEAMS.md, ARCHITECTURE.md, TRUSTED_DEVICES_MFA.md and MODERATION_APPEALS.md, and also link/v4.md - the current protocol - android/THEMING_AND_NAV.md, ci/SONARQUBE.md, installer/PROJECT_TREE.md, modules/kit-acceptance.md, modules/uo/API.md, modules/uo/SCHEMA.md, website/test-plan.md and the two API_V2 documents. The layout block already advertised a ci/ directory that had no section. Every markdown file outside the issue templates is now indexed, and every link resolves. API_V2_SKELETON.md is listed as superseded, which is what its own header says. BACKEND_DESIGN.md was titled "UOMysticmoon Website - Backend Design" though it is core's contract and core is game-agnostic. Retitled, with a note that nothing in it is instance-specific. Its hardcoded public contact address is now described as what it is - seeded from BRAND_CONTACT_EMAIL into the contact_email setting, with UOMysticmoon as the example instance. Co-Authored-By: Claude <noreply@anthropic.com>
7.2 KiB
Website — Architecture
How the pieces of RunicGateway/website fit together. The React SPA and the native mobile app
talk to one Express backend (router → controller → model → db), which persists to MariaDB.
Core is game-agnostic. Since the module system shipped on 2026-08-12, everything about a
particular game — its routes, tables, pages and its connection to a game server — lives in an
installed module, not here. For Ultima Online that is module-uo, which
bridges to the live world only through the uo-link sidecar; the ServUO shard itself is never
internet-facing, dialling out to the sidecar over loopback, and only the sidecar is exposed.
This is the canonical copy of the diagram; the same diagram is embedded in the website's
README.md.
See BACKEND_DESIGN.md for the full API / schema / security contract, and
docs/link/ for the wire protocol between the sidecar and the shard.
flowchart TB
%% ---------- Clients ----------
subgraph clients["Clients"]
browser["Browser<br/>React + Vite SPA<br/>(public · wiki · admin)"]
mobile["Native mobile app<br/>(bearer tokens)"]
end
idp["SSO providers<br/>Google · Discord · custom OIDC"]
discord["Discord"]
%% ---------- Website (one repo) ----------
subgraph website["website/ — Node app (one repo)"]
direction TB
subgraph backend["server/ — Express backend"]
direction TB
mw["Middleware<br/>helmet · siteMode · noindex<br/>rateLimit · loginProtection · botScore · validate"]
router["Router /api/v1<br/>auth (web · mobile · sso) · public · admin · player"]
ctrl["Controllers"]
auth["Session layer (auth/)<br/>sessionService · JWT/cookie · bearer · SSO+PKCE"]
model["Models (.model + .db)<br/>raw parameterized SQL — no ORM"]
sse["SSE fan-out<br/>public stream (allowlist) · admin stream (sensitive)"]
loader["modules/loader.js<br/>scans the volume · mounts · registries · lifecycle"]
secret["secretBox.js<br/>AES-256-GCM secrets at rest"]
end
bot["bot/<br/>Discord bot"]
end
db[("MariaDB<br/>users · posts · wiki · settings · activity<br/>mobileSessions · authProviders · userIdentities<br/>installed_modules · <module>_*")]
%% ---------- Module side ----------
subgraph modside["modules/<id>/ — installed, not built (e.g. Module-uo)"]
direction TB
modsrv["server/ — routers, models, schema fragment<br/>reaches core only through ctx"]
modcli["client/dist/entry.js — prebuilt ESM chunk<br/>React shared via window.__rg"]
end
game["The game<br/>whatever the module talks to<br/>(for Module-uo: a ServUO shard,<br/>via the uo-link sidecar)"]
%% ---------- Edges ----------
browser <-->|"same-origin JSON + SSE (cookie)"| mw
mobile -->|"REST (bearer access/refresh)"| mw
browser -.->|"OAuth redirect + PKCE"| idp
auth -.->|"token exchange"| idp
mw --> router --> ctrl
ctrl --> auth
ctrl --> model
ctrl --> sse
auth --> model
model <--> db
auth -. reads/writes secrets .-> secret
sse -->|"live events"| browser
bot -->|"messages"| discord
bot <--> db
loader -->|"mounts under /api/v1/<tier>/<prefix>"| router
loader -->|"require() + register(ctx, api)"| modsrv
modsrv -->|"ctx.db · ctx.push · ctx.activity …"| model
modsrv <--> game
browser -->|"<script type=module> injected by htmlShell"| modcli
%% ---------- Styling ----------
classDef ext fill:#2d2233,stroke:#7a5c94,color:#e8dff0;
classDef store fill:#1f2d2a,stroke:#4c8c7d,color:#dff0ea;
classDef mod fill:#2d2620,stroke:#94764c,color:#f0e6d8;
class idp,discord,game ext;
class db store;
class modsrv,modcli mod;
Notes on the diagram
- One backend, layered. Every request flows
middleware → router → controller → model → db. Web browsers authenticate with an httpOnly JWT cookie; the native app uses short-lived bearer access tokens plus rotated, hashed, revocable refresh tokens; SSO (Google/Discord/OIDC) is link-only and PKCE-guarded. All three surfaces resolve to the same session model via the session layer, and admin routes are re-validated against the DB on every request. - Core knows nothing about any game. Routes, tables, nav entries, SPA pages and push streams for
a specific game arrive from a module the operator installed; core provides the seams and the
module fills them. Everything in the next three bullets is therefore
module-uo's, not core's — it is described here because it is the worked example every other module is measured against. A module that fails does not take the site down: the loader marks itstartup_failed, the site comes up with its routes and nav absent, and the admin panel says why. - The shard is never reachable. The ServUO shard dials out over loopback TCP
127.0.0.1:7788(newline-delimited JSON) to the uo-link sidecar; only the sidecar is exposed, and only the module's server half talks to it. Every module→sidecar call carriesAuthorization: Bearer <token>and anX-UOLink-Versionheader (a protocol mismatch fails fast with409). The REST client (module-uo'sserver/utils/uoLinkClient.js) never throws — every call returns{ ok, data, status }— so the site degrades gracefully when the shard is down. That guarantee covers reading the config too: resolving the admin-managed config decrypts the stored auth token, which throws when the ciphertext can't be authenticated (SECRET_ENC_KEYrotated, or a DB dump restored under a different key). This is handled inside the client and reported as{ ok: false, status: 0, error: 'uo-link config unreadable' }plus a distinctERROR-level log, so a wrong key degrades the shard surface to "unavailable" instead of 500ing it — andGET /admin/uo-link/configkeeps working, which is the screen an admin needs to re-enter the token and recover. - Two ways in from the sidecar. Live game events arrive over an outbound WebSocket and are
routed by the module's
server/utils/shardIngest.jsdispatcher (state-changing kinds updateshard_*tables, notable kinds append toshard_events, high-frequency kinds only update state). Point-in-time reads and commands go over REST through the sameuoLinkClient.js. - Sensitive events stay private. Ingested events fan out to browsers over two SSE channels — a public allowlist stream and an admin-only stream that additionally carries staff audit, cheat detection, and login-attempt events. Which kinds are public is declared by the module that publishes them, and core enforces the split — the boundary is core's even though the catalog is the module's. Sensitive kinds can never leak onto the public channel.
- Secrets at rest. OAuth client secrets, the uo-link token, and the Gmail refresh token are
AES-256-GCM encrypted via
secretBox.js(keyed bySECRET_ENC_KEY). The uo-link token is write-only in the API — never returned to any client.