Two corrections found by a live smoke test of all 200 routes at every access
level (website PR: fix/uolink-client-throw-and-sitemode-gate).
ARCHITECTURE.md: the "uoLinkClient never throws" invariant was true of the HTTP
call but not of resolving the config, which decrypts the stored auth token and
throws when the ciphertext can't be authenticated (SECRET_ENC_KEY rotated, or a
DB dump restored under a different key). Spell out that this is now handled
inside the client, reported as { ok: false, error: 'uo-link config unreadable' }
with a distinct ERROR log, and that GET /admin/uo-link/config keeps working —
it is the screen an admin needs to re-enter the token and recover.
BACKEND_DESIGN.md: GET /dashboard is staff-wide while PUT /site-mode on the
same screen is adminOnly — the one place a single screen spans two tiers. Note
that the client must gate that control itself rather than relying on the route
gate that admitted the user to the page.
Co-Authored-By: Claude <noreply@anthropic.com>
121 lines
6.2 KiB
Markdown
121 lines
6.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 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/ — 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.
|