Add docs/website/ARCHITECTURE.md (canonical copy of the website architecture Mermaid diagram, with fuller notes) and embed the same diagram in the website-README mirror. Mirrors the diagram added to the website repo README. Co-Authored-By: Claude <noreply@anthropic.com>
5.6 KiB
5.6 KiB
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.
See BACKEND_DESIGN.md for the full API / schema / security contract, and
docs/link/ for the wire protocol between the sidecar and the shard.
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 carriesAuthorization: Bearer <token>and anX-UOLink-Versionheader (a protocol mismatch fails fast with409). 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.jsdispatcher (state-changing kinds updateshard_*tables, notable kinds append toshard_events, high-frequency kinds only update state). Point-in-time reads and commands go over REST throughuoLinkClient.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 bySECRET_ENC_KEY). The uo-link token is write-only in the API — never returned to any client.