Files
docs/website/ARCHITECTURE.md
wtclaude 76da8ee88d docs: hygiene sweep - the four places the docs describe a site that no longer exists
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>
2026-08-19 18:04:41 -05:00

128 lines
7.2 KiB
Markdown

# 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`](../modules/uo/README.md), 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`](https://gitea.whitlocktech.com/RunicGateway/website/src/branch/main/README.md#architecture).
See [BACKEND_DESIGN.md](BACKEND_DESIGN.md) for the full API / schema / security contract, and
[`docs/link/`](../link/) for the wire protocol between the sidecar and the shard.
```mermaid
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/ &nbsp;— 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 · &lt;module&gt;_*")]
%% ---------- Module side ----------
subgraph modside["modules/&lt;id&gt;/ &nbsp;— 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/&lt;tier&gt;/&lt;prefix&gt;"| router
loader -->|"require() + register(ctx, api)"| modsrv
modsrv -->|"ctx.db · ctx.push · ctx.activity …"| model
modsrv <--> game
browser -->|"&lt;script type=module&gt; 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 it `startup_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 carries `Authorization: Bearer
<token>` and an `X-UOLink-Version` header (a protocol mismatch fails fast with `409`). The REST
client (`module-uo`'s `server/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_KEY` rotated, 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 distinct `ERROR`-level log, so a wrong key degrades the shard surface to
"unavailable" instead of 500ing it — and `GET /admin/uo-link/config` keeps 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.js` dispatcher (state-changing kinds update
`shard_*` tables, notable kinds append to `shard_events`, high-frequency kinds only update
state). Point-in-time reads and commands go over **REST** through the same `uoLinkClient.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 by `SECRET_ENC_KEY`). The uo-link token is
write-only in the API — never returned to any client.