From 80f75b1b4ca1fd398a3526b68d314af024278fe6 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 21 Jul 2026 21:13:29 -0500 Subject: [PATCH] docs(website): add architecture diagram 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 --- website/ARCHITECTURE.md | 114 ++++++++++++++++++++++++++++++++++++++ website/website-README.md | 98 ++++++++++++++++++++++++++++++++ 2 files changed, 212 insertions(+) create mode 100644 website/ARCHITECTURE.md diff --git a/website/ARCHITECTURE.md b/website/ARCHITECTURE.md new file mode 100644 index 0000000..284e56e --- /dev/null +++ b/website/ARCHITECTURE.md @@ -0,0 +1,114 @@ +# 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. diff --git a/website/website-README.md b/website/website-README.md index 01cd4e2..7ac7c10 100644 --- a/website/website-README.md +++ b/website/website-README.md @@ -18,6 +18,7 @@ The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, sc ## Contents +- [Architecture](#architecture) - [Tech stack](#tech-stack) - [Project structure](#project-structure) - [Prerequisites](#prerequisites) @@ -37,6 +38,103 @@ The design reference is [BACKEND_DESIGN.md](BACKEND_DESIGN.md) (API contract, sc --- +## Architecture + +How the pieces fit together — the React SPA and native 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 shard itself is never internet-facing. +See [ARCHITECTURE.md](ARCHITECTURE.md) for the fuller write-up. + +```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; +``` + +- **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 refresh tokens; SSO (Google/Discord/OIDC) is link-only and PKCE-guarded. + All three surfaces produce the *same* session via the session layer. +- **The shard is never reachable.** The ServUO shard *dials out* over loopback TCP to the uo-link + sidecar; only the sidecar is exposed, and only the backend talks to it. The REST client + (`uoLinkClient.js`) never throws, so the site degrades gracefully when the shard is down. +- **Sensitive events stay private.** Ingested game events fan out to browsers over two SSE channels — + a public allowlist stream and an admin-only stream that adds staff audit / cheat / login events. + +--- + ## Tech stack | Layer | Tech |