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 |