# 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 and bridges to the live game world **only** through the **uo-link** sidecar. The ServUO shard itself is never internet-facing — it dials 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"] 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)"] subgraph shardutil["Shard integration (utils/)"] ingest["shardIngest.js
WS ingest dispatcher"] restcli["uoLinkClient.js
REST client (never throws)"] end 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
uoLinkConfig · shard_online/economy/houses/events")] %% ---------- Shard side ---------- subgraph shardside["Game shard (never internet-facing)"] direction TB sidecar["uo-link sidecar
(Rust) — the only bridge exposed"] servuo["ServUO shard
(C# plugin)"] end %% ---------- 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 --> restcli ctrl --> sse auth --> model model <--> db auth -. reads/writes secrets .-> secret restcli -. reads config/token .-> secret ingest --> model ingest --> sse sse -->|"live events"| browser bot -->|"messages"| discord bot <--> db restcli -->|"REST: /char /roster /economy /history · /link/confirm · /towncrier"| sidecar sidecar -->|"WebSocket live event feed (bearer + X-UOLink-Version)"| ingest servuo -->|"loopback TCP 127.0.0.1:7788
newline-delimited JSON (shard dials out)"| sidecar %% ---------- Styling ---------- classDef ext fill:#2d2233,stroke:#7a5c94,color:#e8dff0; classDef store fill:#1f2d2a,stroke:#4c8c7d,color:#dff0ea; classDef bridge fill:#2d2620,stroke:#94764c,color:#f0e6d8; class idp,discord ext; class db store; class sidecar,servuo bridge; ``` ## 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. - **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 backend talks to it. Every backend→sidecar call carries `Authorization: Bearer ` and an `X-UOLink-Version` header (a protocol mismatch fails fast with `409`). The REST client (`uoLinkClient.js`) never throws — every call returns `{ ok, data, status }` — so the site degrades gracefully when the shard is down. - **Two ways in from the sidecar.** Live game events arrive over an outbound **WebSocket** and are routed by the `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 `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. The allowlist split is a security boundary; 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.