# 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.