# 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
React + Vite SPA
(public · wiki · admin)"]
mobile["Native mobile app
(bearer tokens)"]
end
idp["SSO providers
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
helmet · siteMode · noindex
rateLimit · loginProtection · botScore · validate"]
router["Router /api/v1
auth (web · mobile · sso) · public · admin · player"]
ctrl["Controllers"]
auth["Session layer (auth/)
sessionService · JWT/cookie · bearer · SSO+PKCE"]
model["Models (.model + .db)
raw parameterized SQL — no ORM"]
sse["SSE fan-out
public stream (allowlist) · admin stream (sensitive)"]
loader["modules/loader.js
scans the volume · mounts · registries · lifecycle"]
secret["secretBox.js
AES-256-GCM secrets at rest"]
end
bot["bot/
Discord bot"]
end
db[("MariaDB
users · posts · wiki · settings · activity
mobileSessions · authProviders · userIdentities
installed_modules · <module>_*")]
%% ---------- Module side ----------
subgraph modside["modules/<id>/ — installed, not built (e.g. Module-uo)"]
direction TB
modsrv["server/ — routers, models, schema fragment
reaches core only through ctx"]
modcli["client/dist/entry.js — prebuilt ESM chunk
React shared via window.__rg"]
end
game["The game
whatever the module talks to
(for Module-uo: a ServUO shard,
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 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
` 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.