# 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<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"]
            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)"]

            subgraph shardutil["Shard integration (utils/)"]
                ingest["shardIngest.js<br/>WS ingest dispatcher"]
                restcli["uoLinkClient.js<br/>REST client (never throws)"]
            end

            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/>uoLinkConfig · shard_online/economy/houses/events")]

    %% ---------- Shard side ----------
    subgraph shardside["Game shard (never internet-facing)"]
        direction TB
        sidecar["uo-link sidecar<br/>(Rust) — the only bridge exposed"]
        servuo["ServUO shard<br/>(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<br/>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 <token>` 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. 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 `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.
