Files
docs/website/ARCHITECTURE.md
wtclaude 76da8ee88d docs: hygiene sweep - the four places the docs describe a site that no longer exists
Four documents still describe the pre-module-system website. None of them
misconfigures anything, which is why they survived; all four mislead a
reader trying to understand how the system is actually put together.

ARCHITECTURE.md placed shardIngest.js and uoLinkClient.js inside the
website backend. Both live in module-uo/server/utils/ - verified, they
are not in website/server/src at all. The document claimed to be "the
canonical copy of the diagram; the same diagram is embedded in the
website's README", and the two had silently diverged: the live README's
diagram has the module subgraph, the loader, and the game behind the
module, and this one did not. The diagram is now the live one verbatim,
the surrounding prose attributes the shard integration to the module, and
the intro no longer frames core as game-aware. The SSE bullet gains the
distinction the live README makes: the module declares which kinds are
public, core enforces the split.

website-README.md had drifted from the live README by 28 lines, all of
them the "Three ways in, and none of them is a build" section - the admin
panel, the MODULES environment variable, and by hand - which is now the
primary module-install story. Re-synced verbatim, since a faithful
snapshot is the file's whole purpose. The diff was purely additive; the
snapshot contained nothing the live README had dropped.

README.md's index was missing thirteen documents, not the four the audit
had found: TEAMS.md, ARCHITECTURE.md, TRUSTED_DEVICES_MFA.md and
MODERATION_APPEALS.md, and also link/v4.md - the current protocol -
android/THEMING_AND_NAV.md, ci/SONARQUBE.md, installer/PROJECT_TREE.md,
modules/kit-acceptance.md, modules/uo/API.md, modules/uo/SCHEMA.md,
website/test-plan.md and the two API_V2 documents. The layout block
already advertised a ci/ directory that had no section. Every markdown
file outside the issue templates is now indexed, and every link resolves.
API_V2_SKELETON.md is listed as superseded, which is what its own header
says.

BACKEND_DESIGN.md was titled "UOMysticmoon Website - Backend Design"
though it is core's contract and core is game-agnostic. Retitled, with a
note that nothing in it is instance-specific. Its hardcoded public
contact address is now described as what it is - seeded from
BRAND_CONTACT_EMAIL into the contact_email setting, with UOMysticmoon as
the example instance.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 18:04:41 -05:00

7.2 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.

Core is game-agnostic. Since the module system shipped on 2026-08-12, everything about a particular game — its routes, tables, pages and its connection to a game server — lives in an installed module, not here. For Ultima Online that is module-uo, which bridges to the live world only through the uo-link sidecar; the ServUO shard itself is never internet-facing, dialling 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/ &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 · player"]
            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)"]
            loader["modules/loader.js<br/>scans the volume · mounts · registries · lifecycle"]
            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/>installed_modules · &lt;module&gt;_*")]

    %% ---------- Module side ----------
    subgraph modside["modules/&lt;id&gt;/ &nbsp;— installed, not built (e.g. Module-uo)"]
        direction TB
        modsrv["server/ — routers, models, schema fragment<br/>reaches core only through ctx"]
        modcli["client/dist/entry.js — prebuilt ESM chunk<br/>React shared via window.__rg"]
    end

    game["The game<br/>whatever the module talks to<br/>(for Module-uo: a ServUO shard,<br/>via the uo-link sidecar)"]

    %% ---------- 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 --> sse
    auth --> model
    model <--> db
    auth -. reads/writes secrets .-> secret
    sse -->|"live events"| browser
    bot -->|"messages"| discord
    bot <--> db

    loader -->|"mounts under /api/v1/&lt;tier&gt;/&lt;prefix&gt;"| router
    loader -->|"require() + register(ctx, api)"| modsrv
    modsrv -->|"ctx.db · ctx.push · ctx.activity …"| model
    modsrv <--> game
    browser -->|"&lt;script type=module&gt; injected by htmlShell"| modcli

    %% ---------- Styling ----------
    classDef ext fill:#2d2233,stroke:#7a5c94,color:#e8dff0;
    classDef store fill:#1f2d2a,stroke:#4c8c7d,color:#dff0ea;
    classDef mod fill:#2d2620,stroke:#94764c,color:#f0e6d8;
    class idp,discord,game ext;
    class db store;
    class modsrv,modcli mod;

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.
  • Core knows nothing about any game. Routes, tables, nav entries, SPA pages and push streams for a specific game arrive from a module the operator installed; core provides the seams and the module fills them. Everything in the next three bullets is therefore module-uo's, not core's — it is described here because it is the worked example every other module is measured against. A module that fails does not take the site down: the loader marks it startup_failed, the site comes up with its routes and nav absent, and the admin panel says why.
  • 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 module's server half talks to it. Every module→sidecar call carries Authorization: Bearer <token> and an X-UOLink-Version header (a protocol mismatch fails fast with 409). The REST client (module-uo's server/utils/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 module's server/utils/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 the same 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. Which kinds are public is declared by the module that publishes them, and core enforces the split — the boundary is core's even though the catalog is the module's. 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.