Compare commits
1 Commits
6398285a13
...
docs/insta
| Author | SHA1 | Date | |
|---|---|---|---|
| 152ffef86e |
20
README.md
20
README.md
@@ -7,23 +7,17 @@ so they live in one place, independent of either codebase.
|
||||
## Layout
|
||||
|
||||
```
|
||||
website/ docs from the shard website (Node/Express + MariaDB + React/Vite)
|
||||
link/ docs from the ServUO bridge (C# plugin + Rust sidecar + Node WS)
|
||||
android/ docs from the native Android client (Kotlin + Jetpack Compose)
|
||||
installer/ docs for the installer that deploys a shard's bridge components
|
||||
ci/ cross-cutting CI/quality notes
|
||||
website/ docs from the shard website (Node/Express + MariaDB + React/Vite)
|
||||
link/ docs from the ServUO bridge (C# plugin + Rust sidecar + Node WS)
|
||||
android/ docs from the native Android client (Kotlin + Jetpack Compose)
|
||||
ci/ cross-cutting CI/quality notes
|
||||
```
|
||||
|
||||
**Setting up a shard?** [`installer/INSTALL.md`](installer/INSTALL.md) is the operator guide, and
|
||||
the installer is the supported path: one binary deploys the plugin overlay, installs the uo-link
|
||||
sidecar as a service, and hands you the values the website needs.
|
||||
|
||||
### `website/`
|
||||
| Doc | What it covers |
|
||||
|---|---|
|
||||
| [BACKEND_DESIGN.md](website/BACKEND_DESIGN.md) | API contract, DB schema, security model |
|
||||
| [HERO_EDITOR.md](website/HERO_EDITOR.md) | Hero canvas editor feature spec |
|
||||
| [THEMING_AND_NAV.md](website/THEMING_AND_NAV.md) | Admin-configurable theme, brand assets and navigation — build contract |
|
||||
| [WIKI_UPGRADE.md](website/WIKI_UPGRADE.md) | Wiki subsystem upgrade notes |
|
||||
| [SHARD_VISIBILITY.md](website/SHARD_VISIBILITY.md) | Who sees which shard data — the admin-configurable audience framework |
|
||||
| [SPAWN_ATLAS.md](website/SPAWN_ATLAS.md) | The bestiary / spawn atlas: what the shard contains, parsed from its own ServUO tree |
|
||||
@@ -56,12 +50,6 @@ sidecar as a service, and hands you the values the website needs.
|
||||
| [TRUSTED_DEVICES_APP_HANDOFF.md](android/TRUSTED_DEVICES_APP_HANDOFF.md) | Trusted-devices app handoff notes |
|
||||
| [PROJECT_TREE.md](android/PROJECT_TREE.md) | Auto-generated snapshot of the repo's tracked file layout |
|
||||
|
||||
### `installer/`
|
||||
| Doc | What it covers |
|
||||
|---|---|
|
||||
| [INSTALL.md](installer/INSTALL.md) | **Start here to set up a shard** — the installer deploys the plugin overlay and the uo-link sidecar, registers the service, and connects it to the website. Appendix A is the same thing by hand, still supported |
|
||||
| [PLAN.md](installer/PLAN.md) | Installer design of record — phases, locked decisions, the bundle/compat-matrix model |
|
||||
|
||||
## Provenance
|
||||
|
||||
- `website/*` was extracted from `RunicGateway/website` via `git filter-repo`.
|
||||
|
||||
@@ -1,904 +0,0 @@
|
||||
# Installing Runic Gateway on your shard
|
||||
|
||||
Operator guide for the **Runic Gateway installer** — the tool that takes a working ServUO
|
||||
installation and connects it to a Runic Gateway website.
|
||||
|
||||
> **The installer is the supported way to set this up.** Download one binary, run `install`, paste
|
||||
> four values into your website. It deploys the plugin overlay, installs the uo-link sidecar and
|
||||
> registers it as a service, and gives you [`doctor`, `update` and `uninstall`](#7-day-two)
|
||||
> afterwards. Start at [§1](#1-download-and-verify).
|
||||
>
|
||||
> [Appendix A](#appendix-a--installing-by-hand) is the same deployment done by hand. It is
|
||||
> **supported, not deprecated** — use it on a host that cannot run the binary, when you want to
|
||||
> place things yourself, or when you are developing on the bridge and installing from a working
|
||||
> tree rather than a release. It is also the reference for what the installer does under the hood.
|
||||
>
|
||||
> Design of record: [PLAN.md](PLAN.md).
|
||||
|
||||
---
|
||||
|
||||
## What this installs
|
||||
|
||||
Three things, on the machine that runs your shard:
|
||||
|
||||
| # | Component | Where it comes from |
|
||||
|---|---|---|
|
||||
| 1 | **The plugin overlay** — C# source that ServUO compiles at boot, copied into your server tree | [`RunicGateway/servuo-plugins`](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins) release tarball |
|
||||
| 2 | **The uo-link sidecar** — a small Rust service that the shard dials out to, and that your website reads from | [`RunicGateway/link`](https://gitea.whitlocktech.com/RunicGateway/link) release binary |
|
||||
| 3 | **A record of what it did** — `install.json`, plus cached copies of the patches and of every file the patch tier edited | Written by the installer |
|
||||
|
||||
```
|
||||
ServUO shard ──loopback TCP 127.0.0.1:7788──► uo-link sidecar ──HTTP + WebSocket──► website
|
||||
(1) overlay (2) binary + service (yours)
|
||||
```
|
||||
|
||||
The shard **dials out**; it never listens for the website and is never reachable from the internet.
|
||||
Only the sidecar is exposed, and only to your website.
|
||||
|
||||
### What it deliberately does not do
|
||||
|
||||
- **It never restarts or manages ServUO.** Your shard keeps starting the way it always has. The
|
||||
installer refuses to run while ServUO is up, and tells you when a restart is required.
|
||||
- **It never deletes anything from your server tree.** The overlay sync only adds and overwrites.
|
||||
- **It never contacts your website.** It prints four values for you to paste into Admin → Shard.
|
||||
- **It never edits stock ServUO files without asking.** That is the opt-in
|
||||
[patch tier](#4-the-patch-tier-optional), and skipping it still leaves you with a working bridge.
|
||||
|
||||
---
|
||||
|
||||
## Before you begin
|
||||
|
||||
| Requirement | Detail |
|
||||
|---|---|
|
||||
| A working ServUO install | It must currently boot and compile scripts cleanly. The installer deploys onto a healthy shard; it does not repair a broken one. |
|
||||
| ServUO **57.4** *(patch tier only)* | **57.4 is the only supported version.** The base install works on any reasonably current ServUO. The patch tier is written and tested against stock 57.4; on any other version it is **unsupported and untested** — you can still choose to run it, behind an explicit opt-in, and it applies only where the exact lines it patches are unchanged. See [§4](#4-the-patch-tier-optional). |
|
||||
| ServUO **stopped** | `ServUO.exe` holds a lock on `Scripts.dll` and writes `Saves/` on exit. The installer refuses to deploy under a running shard. |
|
||||
| Administrator / root | It writes into system directories and registers a service. |
|
||||
| Outbound HTTPS | To `gitea.whitlocktech.com`, to fetch the bundle and the two artifacts. Nothing inbound is needed, and no Gitea account or git client is required. |
|
||||
| The sidecar on the **same host** as the shard | The shard connects to `127.0.0.1:7788`. Splitting them is not supported — the loopback socket *is* the trust boundary for inbound commands. |
|
||||
| Admin access to your Runic Gateway site | The last step is pasting four values into Admin → Shard. |
|
||||
|
||||
**Back up first.** The overlay overwrites `Scripts/Scripts.csproj` (a stock file), and the patch
|
||||
tier edits stock sources. A copy of `Scripts/` and `Config/` before you start costs nothing.
|
||||
|
||||
---
|
||||
|
||||
## 1. Download and verify
|
||||
|
||||
Releases are **unsigned**. There is no code-signing certificate and no notarization, so the
|
||||
`SHA256SUMS` file published beside every artifact is the whole trust anchor — check it.
|
||||
|
||||
Download the installer for your OS, plus `SHA256SUMS`, from the
|
||||
[installer releases page](https://gitea.whitlocktech.com/RunicGateway/installer/releases):
|
||||
|
||||
```
|
||||
runicgateway-installer-linux-x86_64
|
||||
runicgateway-installer-linux-aarch64
|
||||
runicgateway-installer-windows-x86_64.exe
|
||||
SHA256SUMS
|
||||
```
|
||||
|
||||
`linux-aarch64` is for arm64 hosts — Ampere/Graviton instances, Pi-class boxes. `uname -m` says
|
||||
`aarch64` on those and `x86_64` otherwise. There is no macOS build and no Windows-on-arm build: the
|
||||
shard dials the sidecar out on loopback, so the two have to share a host, and no ServUO host is
|
||||
either of those.
|
||||
|
||||
**Linux**
|
||||
|
||||
```bash
|
||||
sha256sum -c SHA256SUMS --ignore-missing
|
||||
chmod +x runicgateway-installer-linux-x86_64
|
||||
```
|
||||
|
||||
**Windows** (PowerShell)
|
||||
|
||||
```powershell
|
||||
(Get-FileHash .\runicgateway-installer-windows-x86_64.exe -Algorithm SHA256).Hash
|
||||
Get-Content .\SHA256SUMS # compare the line for this file, case-insensitively
|
||||
```
|
||||
|
||||
Windows will show a **SmartScreen "Windows protected your PC"** prompt on first run, because the
|
||||
binary is unsigned and unknown. Once you have verified the checksum above: *More info* →
|
||||
*Run anyway*. If you would rather not, Appendix A's manual path uses no unsigned binary except the
|
||||
sidecar itself, which you verify the same way.
|
||||
|
||||
The installer applies the same standard to everything **it** downloads: each artifact's SHA256 is
|
||||
checked against the value recorded in the bundle manifest — which CI computed after verifying it
|
||||
against the publishing repo's own `SHA256SUMS` — and a mismatch aborts the run.
|
||||
|
||||
### What it installs is a bundle, not "latest"
|
||||
|
||||
The three components version independently but must agree on one wire protocol, so CI publishes a
|
||||
[**bundle**](https://gitea.whitlocktech.com/RunicGateway/installer/src/branch/main/bundles/README.md):
|
||||
one exact, protocol-checked pair of sidecar + overlay versions. The installer resolves that at run
|
||||
time rather than hardcoding versions or blindly taking each repo's newest release.
|
||||
|
||||
Consequences worth knowing:
|
||||
|
||||
- A sidecar patch release does **not** mean re-downloading the installer. The bundle is data.
|
||||
- `--bundle <tag>` (e.g. `--bundle 2026.08.04`) pins an exact past combination, so a reinstall six
|
||||
months from now reproduces today's install rather than tomorrow's.
|
||||
|
||||
---
|
||||
|
||||
## 2. Run it
|
||||
|
||||
```bash
|
||||
sudo ./runicgateway-installer-linux-x86_64 install
|
||||
```
|
||||
|
||||
```powershell
|
||||
# Windows: from an elevated PowerShell
|
||||
.\runicgateway-installer-windows-x86_64.exe install
|
||||
```
|
||||
|
||||
Run `install --verify` first if you want to see exactly what would change and write nothing — the
|
||||
same idea as `deploy.ps1 -Verify`, which developers of the plugin use.
|
||||
|
||||
The installer **does not install itself.** Keep the binary somewhere sensible on the host (it is
|
||||
one file); `doctor`, `update` and `uninstall` are run from it later. Examples below shorten it to
|
||||
`runicgateway`.
|
||||
|
||||
### What it asks
|
||||
|
||||
1. **Your ServUO root** — detected if the installer is run from inside it or from an obvious
|
||||
sibling, otherwise prompted. A directory qualifies only if it contains `ServUO.exe`, `Scripts/`
|
||||
and `Config/`.
|
||||
2. **Whether to apply the patch tier** — off unless you say yes. On a ServUO that is not 57.4 the
|
||||
prompt defaults to **no** and carries an unsupported-version warning you have to answer past.
|
||||
See [§4](#4-the-patch-tier-optional).
|
||||
3. **The hostname your website should use to reach this machine** — used only to compose the two
|
||||
URLs it prints at the end. The sidecar's bind address is frequently `127.0.0.1` or `0.0.0.0`,
|
||||
neither of which is something to hand to a website.
|
||||
4. **Your site's URL** — used only to print a clickable link to its Admin → Shard page. The
|
||||
installer never contacts your website.
|
||||
|
||||
### An illustrative run
|
||||
|
||||
```
|
||||
Runic Gateway installer — bundle 2026.08.04 (protocol 3)
|
||||
|
||||
ServUO /opt/ServUO (57.4)
|
||||
Shard process not running
|
||||
Overlay servuo-plugins v0.1.1 protocol 3
|
||||
Sidecar uo-link v1.1.0 protocol 3
|
||||
|
||||
✓ overlay tarball verified sha256 75dc6d6c…
|
||||
✓ sidecar binary verified sha256 27d491ef…
|
||||
|
||||
Overlay sync
|
||||
ADD Config/Bridge.cfg
|
||||
ADD Scripts/Custom/Bridge/*.cs (22 files)
|
||||
CHANGE Scripts/Scripts.csproj
|
||||
deployed. add=23 change=1 unchanged=0 kept=0
|
||||
|
||||
Patch tier not selected
|
||||
Without it: no vendor.sale events, no in-game moderation audit forwarding.
|
||||
|
||||
uo-link sidecar
|
||||
binary /usr/bin/runicgateway-link install
|
||||
✓ sidecar binary verified sha256 27d491ef…
|
||||
config /etc/runicgateway/sidecar.toml created
|
||||
database /var/lib/runicgateway/uo-link.db
|
||||
listening on shard 127.0.0.1:7788 website 127.0.0.1:8080
|
||||
service runicgateway-link.service active, enabled
|
||||
running as runicgateway
|
||||
|
||||
Recorded /etc/runicgateway/install.json
|
||||
|
||||
Scripts.csproj changed — ServUO rebuilds Scripts.dll on next boot.
|
||||
Start your shard when ready; the installer does not start it for you.
|
||||
```
|
||||
|
||||
Then the [token handoff](#5-connect-the-website).
|
||||
|
||||
### Commands and flags
|
||||
|
||||
The surface this guide specifies. Each command is idempotent: a second run with nothing new to do
|
||||
reports "unchanged" and writes nothing.
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `install` | The full run above. |
|
||||
| `doctor` | Diagnoses an existing deployment end to end — see [§7](#7-day-two). |
|
||||
| `update` | Re-resolves the bundle; updates the sidecar (replace + restart) and the overlay (re-sync + tell you to restart ServUO). |
|
||||
| `uninstall` | Removes only what the installer exclusively owns; prints — never performs — anything inside your ServUO tree. |
|
||||
|
||||
| Flag | Applies to | Meaning |
|
||||
|---|---|---|
|
||||
| `--verify` | `install`, `update` | Dry run. Report every change that would be made; write nothing. |
|
||||
| `--servuo <path>` | `install`, `doctor`, `update` | Name the ServUO root instead of detecting or prompting. |
|
||||
| `--bundle <tag>` | `install`, `update` | Pin an exact published bundle instead of the current one. |
|
||||
| `--patches` / `--no-patches` | `install`, `update` | Decide the patch tier non-interactively. `--patches` never loosens the region check: patches whose target lines are not stock are reported for you to apply by hand, not forced. On `update` it is what takes up a feature the shard does not already have. |
|
||||
| `--patches-unsupported-servuo` | `install` | Required *in addition to* `--patches` to run the patch tier on a ServUO that is not 57.4. Unsupported and untested — see [§4](#4-the-patch-tier-optional). Ignored on 57.4. |
|
||||
| `--host <name>` | `install` | The hostname to print in the website URLs. |
|
||||
| `--site-url <url>` | `install` | Your site's base URL, for the Admin → Shard link. |
|
||||
| `--yes` | all | Assume the default answer to every prompt. Combine with the flags above for an unattended run. **On `uninstall` it means yes** — that prompt defaults to no, and typing `uninstall --yes` is not an accident. |
|
||||
| `--no-backup` | `install`, `update` | Do not copy the files this run is about to overwrite. They are otherwise saved under the state directory — see [§7](#7-day-two). |
|
||||
| `--purge` | `uninstall` | Also delete `sidecar.toml`, `uo-link.db`, the cached patch set and every backup, all of which are otherwise kept. |
|
||||
|
||||
Exit codes are `0` success, `1` the run failed, `2` the arguments were unusable. Two commands also
|
||||
use `1` for a run that *completed* and found something wrong, so they can be read from a script:
|
||||
`doctor` when any check failed, and `uninstall` when a step could not be carried out (everything
|
||||
else still was).
|
||||
|
||||
---
|
||||
|
||||
## 3. Where everything lands
|
||||
|
||||
**Linux**
|
||||
|
||||
| Path | What |
|
||||
|---|---|
|
||||
| `/usr/bin/runicgateway-link` | The sidecar binary |
|
||||
| `/etc/runicgateway/sidecar.toml` | Sidecar config, including the auth token |
|
||||
| `/etc/runicgateway/install.json` | What the installer deployed: versions, commit, per-file hashes, applied patches, timestamps |
|
||||
| `/etc/runicgateway/patches/` | Copies of the patches the tier evaluated, so `uninstall` can print the exact hunks long after the release tarball is gone, and a refused one is still on hand to apply yourself |
|
||||
| `/etc/runicgateway/patches/originals/` | Each file the patch tier edited, exactly as it was beforehand — a revert you can verify rather than reconstruct |
|
||||
| `/etc/runicgateway/backups/<timestamp>/` | Copies of the files a run replaced, with a `manifest.json` naming each. Newest three kept; skip with `--no-backup` |
|
||||
| `/var/lib/runicgateway/uo-link.db` | The sidecar's SQLite store (event history, cached profiles, link map) |
|
||||
| `/etc/systemd/system/runicgateway-link.service` | The service unit, running as a dedicated user |
|
||||
|
||||
**Windows**
|
||||
|
||||
| Path | What |
|
||||
|---|---|
|
||||
| `%ProgramFiles%\RunicGateway\uo-link-sidecar.exe` | The sidecar binary |
|
||||
| `%ProgramData%\RunicGateway\sidecar.toml` | Sidecar config, including the auth token |
|
||||
| `%ProgramData%\RunicGateway\install.json` | As above |
|
||||
| `%ProgramData%\RunicGateway\patches\` | As above |
|
||||
| `%ProgramData%\RunicGateway\patches\originals\` | As above |
|
||||
| `%ProgramData%\RunicGateway\backups\<timestamp>\` | As above |
|
||||
| `%ProgramData%\RunicGateway\uo-link.db` | The sidecar's SQLite store |
|
||||
| `%ProgramData%\RunicGateway\uo-link-sidecar.<date>.log` | The service's log. A Windows service has no console to write to, so it logs here instead; rolled daily, seven kept. A foreground run still logs to stdout as usual |
|
||||
| Service `RunicGatewayLink` | Automatic start, restart on failure, running as `NT SERVICE\RunicGatewayLink`. Needs a sidecar **v1.2.0 or newer** — see [Troubleshooting](#troubleshooting) on error 1053 |
|
||||
|
||||
**Inside your ServUO tree** (added by the overlay sync — 24 files):
|
||||
|
||||
```
|
||||
Config/Bridge.cfg every bridge setting, heavily commented
|
||||
Scripts/Custom/Bridge/*.cs 22 files: the plugin itself
|
||||
Scripts/Scripts.csproj OVERWRITES a stock file (see below)
|
||||
```
|
||||
|
||||
**Both service definitions pin the config path**, because the sidecar's own default is relative to
|
||||
its working directory — and a service manager's working directory is not somewhere you want a
|
||||
database or a config file. On Windows it can be `%SystemRoot%\System32` or, under
|
||||
`C:\Program Files\`, a silently redirected VirtualStore copy.
|
||||
|
||||
How the *database* path is pinned differs by platform, and that is deliberate:
|
||||
|
||||
| | Config | Database |
|
||||
|---|---|---|
|
||||
| **Linux** | `Environment=UOLINK_CONFIG=` in the unit | `Environment=UOLINK_DB_PATH=` in the unit — `/etc` and `/var/lib` are different directories, so both need naming |
|
||||
| **Windows** | `--config` inside the service's own `binPath` | nothing to set: a relative `[store] path` resolves against the config's directory, which *is* `%ProgramData%\RunicGateway` |
|
||||
|
||||
The Windows service would otherwise need a **machine-wide** environment variable — `sc.exe` has no
|
||||
per-service one — which every process on the host inherits and which outlives an uninstall.
|
||||
|
||||
**Both run as a dedicated, unprivileged account.** Linux gets a `runicgateway` system user; Windows
|
||||
gets a virtual service account, `NT SERVICE\RunicGatewayLink`, which Windows creates as part of
|
||||
registering the service and which has no password. Neither runs as root or `LocalSystem`.
|
||||
|
||||
**`sidecar.toml` is locked down, because it holds your auth token.** Neither default location
|
||||
protects it on its own — `/etc` is world-readable, and `%ProgramData%` grants `Users` read access by
|
||||
inheritance — so the installer sets the permissions itself: `chmod 600` plus `chown` to the service
|
||||
user on Linux, and an explicit ACL of SYSTEM, Administrators and the service account on Windows.
|
||||
|
||||
> **`Scripts.csproj` is overwritten deliberately.** The stock file omits `Scripts/Custom/`, so the
|
||||
> plugin would sit in the tree and never compile — and ServUO would not tell you, because it
|
||||
> ignores the script build's exit code and silently reloads the previous `Scripts.dll`. That
|
||||
> failure mode is the reason [§6](#6-start-servuo-and-verify) exists.
|
||||
|
||||
---
|
||||
|
||||
## 4. The patch tier (optional)
|
||||
|
||||
Most of the plugin ships as **added** files, which is why the base install is a safe file copy. Two
|
||||
features cannot: they need edits to stock ServUO sources, because the events they depend on do not
|
||||
exist.
|
||||
|
||||
| Patch | Edits | Gives you | Rebuild needed |
|
||||
|---|---|---|---|
|
||||
| `playervendor-sale-eventsink.patch` + `playervendor-sale-gump.patch` | `Server/EventSink.cs`, `Scripts/Gumps/PlayerVendorGumps.cs` | `vendor.sale` events — player-vendor purchases with buyer, owner, price and commission, which is what cheat detection needs | **Core solution rebuild** (`dotnet build ServUO.sln`) — the dynamic script build is not enough |
|
||||
| `commandlogging-event.patch` | `Scripts/Commands/Logging.cs` | In-game moderation actions (`[ban`, `[kick`, `[bcast`) forwarded to the website's moderation log as `admin.audit` | Script build only — a shard restart is enough |
|
||||
|
||||
Each patch has a companion `.cs` file that is copied **only after** its patch applies, because it
|
||||
references symbols the patch introduces. That is why they are not in the base overlay: shipping them
|
||||
unconditionally would break the build on every unpatched install.
|
||||
|
||||
How the installer handles it:
|
||||
|
||||
- **Opt-in.** The base install completes without it, and declining is a supported outcome, not a
|
||||
degraded one.
|
||||
- **Dry-run first, always.** Every patch is checked before anything is applied, and reported per
|
||||
patch. Most real shards are hand-modified; a patch that does not apply is expected, not alarming.
|
||||
- **A modified file is not automatically a refusal.** These patches touch three small regions of
|
||||
three large files. If you have edited `Logging.cs` somewhere else entirely, the installer says so
|
||||
and still applies the patch — it checks whether *the lines the patch edits* are still stock, not
|
||||
whether the whole file is. It applies only where the surrounding lines match the patch exactly and
|
||||
appear exactly once; anything less and it stops and hands you the hunk to apply by hand. It never
|
||||
force-fits a patch by loosening the match.
|
||||
- **All or nothing per feature.** The two vendor-sale patches are one unit and are applied together
|
||||
or not at all — and within a patch, if one hunk cannot be placed safely, none are. A patch that
|
||||
could have been placed but was held back by its sibling says exactly that; it is never reported as
|
||||
applied.
|
||||
- **It does not need `git`, and does not use it.** The matching and the writing are the installer's
|
||||
own, which is why it can place a patch on a shard where `git apply` refuses — the shipped patches
|
||||
and their target files do not all use the same line endings, and that alone defeats `git apply`.
|
||||
Nothing outside a patched region is touched, down to the byte, and inserted lines take your file's
|
||||
own line ending.
|
||||
- **Your ServUO version is reported, not decisive** — but see the warning below before running this
|
||||
on anything other than 57.4.
|
||||
- **Recorded, and the `.patch` files cached**, so re-runs stay idempotent and `uninstall` can print
|
||||
the exact hunks to revert — along with how each was applied, since a patch placed into a file you
|
||||
had already modified is one to look at more carefully when reverting. Patches that were *not*
|
||||
applied are cached too, because that is the copy the run tells you to apply by hand.
|
||||
- **A copy of every file it edits is kept, exactly as it was beforehand**, under
|
||||
`patches/originals/` in the installer's own directory — not in your ServUO tree. It is written
|
||||
before the first edit and never overwritten, so however many times you re-run `install`, it stays
|
||||
the version from before the tier ever touched the file. That is what lets you verify a revert
|
||||
rather than reconstruct one.
|
||||
- **Re-running is safe.** A patch already in place is recognised and left alone, and the record
|
||||
keeps the way it originally landed rather than relabelling it.
|
||||
|
||||
### ⚠ On any ServUO that is not 57.4: unsupported, untested, no guarantees
|
||||
|
||||
> **Runic Gateway is designed, built and tested against stock ServUO 57.4.** That is the only
|
||||
> supported version.
|
||||
>
|
||||
> On any other version — a newer release, an older one, or a fork — the patch tier is
|
||||
> **UNSUPPORTED, UNTESTED, and NOT GUARANTEED TO WORK.** You may run it. If you do, you are on your
|
||||
> own: it is not covered by support, and a bad outcome may not show up until your shard is live,
|
||||
> because ServUO's script build reports success even when it failed and quietly keeps running the
|
||||
> previous `Scripts.dll`.
|
||||
>
|
||||
> The installer will still refuse to place a patch anywhere the exact lines it edits have changed —
|
||||
> but matching text is not the same as matching behaviour. A hunk can land correctly and still be
|
||||
> wrong for a tree that has diverged around it.
|
||||
>
|
||||
> **Back up your ServUO tree first, and verify your shard boots and compiles afterwards.**
|
||||
|
||||
Because of that, on a non-57.4 tree the tier is off by default and takes a deliberate yes:
|
||||
|
||||
- the interactive prompt defaults to **no** and prints the warning above;
|
||||
- `--patches` on its own is **not** enough — an unattended run must also pass
|
||||
`--patches-unsupported-servuo`;
|
||||
- the choice is recorded, and `doctor` keeps showing an unsupported-version row for the life of the
|
||||
install — so whoever looks after this shard next can see it without being told.
|
||||
|
||||
A run where the tier is selected on a shard that has been worked on looks like this:
|
||||
|
||||
```
|
||||
Patch tier 2 of 3 applied
|
||||
✓ playervendor-sale-eventsink Server/EventSink.cs
|
||||
stock file — applied at line 171, 1521, 1771, 2416
|
||||
✓ playervendor-sale-gump Scripts/Gumps/PlayerVendorGumps.cs
|
||||
file modified, patched region stock — applied at line 95
|
||||
✗ commandlogging-event Scripts/Commands/Logging.cs
|
||||
patched region has been modified (hunk 1) — not applied
|
||||
apply this by hand, then re-run install to record it:
|
||||
/etc/runicgateway/patches/commandlogging-event.patch
|
||||
|
||||
⚠ Server/EventSink.cs — a CORE ServUO file was patched. Rebuild the solution:
|
||||
dotnet build ServUO.sln
|
||||
A shard restart is not enough; ServUO's dynamic script build does not rebuild the core, and it
|
||||
will not tell you so.
|
||||
|
||||
Not applied, so you do not get: no in-game moderation audit forwarding.
|
||||
Everything else works. Apply the hunks by hand if you want them, then re-run install to record it.
|
||||
```
|
||||
|
||||
The line numbers are where each hunk was actually found in *your* file, not where it sits in stock
|
||||
ServUO — they differ as soon as anything above the region has been edited, and yours is the one to
|
||||
go to.
|
||||
|
||||
If it is skipped or fails, you lose exactly two things — **`vendor.sale` events** and **in-game
|
||||
moderation audit forwarding**. Everything else works. You can apply the patches later by hand (see
|
||||
`patches/README.md` in the tarball) and re-run `install` to record it.
|
||||
|
||||
---
|
||||
|
||||
## 5. Connect the website
|
||||
|
||||
The installer ends a successful run by printing the one manual step it cannot do for you:
|
||||
|
||||
```
|
||||
Runic Gateway is installed.
|
||||
|
||||
One manual step remains — connect the website to this sidecar:
|
||||
|
||||
Base URL http://shard.example.com:8080
|
||||
WebSocket URL ws://shard.example.com:8080/ws
|
||||
Protocol version 3
|
||||
Auth token 4f9c… (also in /etc/runicgateway/sidecar.toml)
|
||||
|
||||
Paste these into Admin → Shard on your Runic Gateway site:
|
||||
https://your-site.example/admin/shard
|
||||
|
||||
The token is write-only once saved — the site will never show it back to you.
|
||||
```
|
||||
|
||||
Every value there comes from asking the installed sidecar itself (`--print-config`), not from a
|
||||
log file or a guess, so it cannot drift from what the service actually runs.
|
||||
|
||||
On your site, sign in as an administrator and open **Admin → Shard (uo-link)**:
|
||||
|
||||
| Field on the page | Paste |
|
||||
|---|---|
|
||||
| Enable the shard integration | ✔ on |
|
||||
| Base URL (REST) | the **Base URL** line |
|
||||
| WebSocket URL (feed) | the **WebSocket URL** line |
|
||||
| Auth token | the **Auth token** line |
|
||||
| Protocol | the **Protocol version** line (`3`) |
|
||||
|
||||
Saving restarts the site's ingest client, so the change takes effect immediately. The token is
|
||||
AES-GCM encrypted at rest and **never returned to any client** — losing it means reading it back
|
||||
from `sidecar.toml` on the shard host, not from the website.
|
||||
|
||||
### If your website is on a different machine
|
||||
|
||||
The sidecar binds `127.0.0.1:8080` by default, which is reachable only from the shard host. If your
|
||||
website runs elsewhere, you must widen the bind — and then narrow the access:
|
||||
|
||||
1. Set `[web] bind` in `sidecar.toml` to `0.0.0.0:8080` (or a specific LAN address) and restart the
|
||||
service.
|
||||
2. **Firewall port 8080 to your website's address only.** The auth token is always on, but it
|
||||
travels as a plain bearer token — the sidecar speaks HTTP, not HTTPS.
|
||||
3. If the two hosts are not on a trusted network, put the sidecar behind a TLS reverse proxy or a
|
||||
VPN/WireGuard link, and give the website the proxied `https://` / `wss://` URLs.
|
||||
|
||||
The `[shard] bind` line is a different matter: leave it on `127.0.0.1:7788`. That socket accepts
|
||||
*inbound commands* to the game, and being loopback-only is what makes that safe.
|
||||
|
||||
---
|
||||
|
||||
## 6. Start ServUO and verify
|
||||
|
||||
Start your shard the way you always do. Then confirm the bridge is actually live — not merely
|
||||
installed. **A successful file copy is not a working bridge**: ServUO shells out to `dotnet build`,
|
||||
prints the output, ignores the exit code, and reloads the existing `Scripts.dll`, so a broken script
|
||||
build looks exactly like a clean boot.
|
||||
|
||||
**a. Watch the boot output.** You want to see the build succeed *and* the bridge announce itself:
|
||||
|
||||
```
|
||||
Core: Compiling scripts...
|
||||
Build succeeded.
|
||||
[Bridge] enabled=True endpoint=127.0.0.1:7788 queueCap=10000 sweeps(stat=30s decay=60s …
|
||||
```
|
||||
|
||||
If you scrolled past it, force the question:
|
||||
|
||||
```bash
|
||||
cd <servuo root>
|
||||
dotnet build Scripts/Scripts.csproj -c Release -p:Platform=x64 # must be 0 errors
|
||||
```
|
||||
|
||||
**b. Ask the shard, in game.** As an Administrator:
|
||||
|
||||
```
|
||||
[bridge status
|
||||
```
|
||||
|
||||
It reports the config plus `connected=True depth=0 sent=… dropped=0 …`. `connected=False` means the
|
||||
shard cannot reach the sidecar; `dropped` climbing means the sidecar is wedged and the shard is
|
||||
shedding events rather than stalling — which it is designed to do. `[bridge reload` re-reads
|
||||
`Bridge.cfg` without a restart; `[bridge sweepnow` forces one pass of every stream.
|
||||
|
||||
**c. Ask the sidecar.** `/health` needs no auth, so it is safe to curl from a terminal:
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:8080/health
|
||||
{"status":"ok","protocol":3,"plugin_connected":true,"database":"ok","uptime":"2m","last_event":"2026-08-04T18:22:10.412Z"}
|
||||
```
|
||||
|
||||
`plugin_connected: true` is the one that matters — it is the only value in this whole guide that
|
||||
distinguishes "files copied" from "the bridge works".
|
||||
|
||||
**d. Ask the website.** The public site should stop showing the shard as offline, and live events
|
||||
should appear on the admin dashboard within seconds.
|
||||
|
||||
---
|
||||
|
||||
## 7. Day two
|
||||
|
||||
### `runicgateway doctor`
|
||||
|
||||
The command that makes this supportable. Run it before asking anyone for help — its output is the
|
||||
first thing a maintainer will want.
|
||||
|
||||
```
|
||||
✓ Install record /etc/runicgateway/install.json (bundle 2026.08.04, installer 1.0.0, …)
|
||||
✓ ServUO found /opt/ServUO (57.4)
|
||||
✓ Overlay in sync 24 files, all hashes match install.json
|
||||
⚠ Patch tier 1 applied — moderation-audit (region-match)
|
||||
✓ uo-link installed uo-link-sidecar 1.1.0 (protocol 3)
|
||||
config /etc/runicgateway/sidecar.toml database /var/lib/runicgateway/uo-link.db
|
||||
✓ Service runicgateway-link.service active, enabled as runicgateway
|
||||
✓ Sidecar reachable 127.0.0.1:8080 /health ok, up 6h, database ok
|
||||
✓ Protocol sidecar 3 = overlay manifest 3
|
||||
✗ Shard connected no — the shard is running (pid 8123) but has not dialed in
|
||||
✓ Bundle 2026.08.04 — up to date
|
||||
✓ Backups 2026-08-04T09:12:44Z — 3 file(s) replaced by update to bundle 2026.08.04
|
||||
3 kept in /etc/runicgateway/backups
|
||||
```
|
||||
|
||||
Rows come from asking the installed sidecar (`--version`, `--print-config`) rather than from reading
|
||||
`install.json`, so `doctor` reports what the binary would actually do — including which config and
|
||||
database file the *service* resolves — rather than what the installer believes it was told. The
|
||||
overlay row compares live file hashes against `install.json`, which is how it tells "you edited a
|
||||
deployed file" from "the file is gone"; the bundle row is what tells you the overlay upstream has
|
||||
moved on. Each patched file is re-checked against the cached copy of its patch, so a core upgrade or
|
||||
a restored backup that quietly removed the tier's edits is caught here — nothing else would notice.
|
||||
|
||||
It writes nothing at all, and it is safe to run while the shard is up; that is in fact the only
|
||||
state in which the last row can be `✓`.
|
||||
|
||||
**Reading the marks:**
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `✓` | as it should be |
|
||||
| `⚠` | worth knowing, not broken — a stopped shard, a service you never registered, an unpatched tier, or no route to Gitea to check for a newer bundle |
|
||||
| `✗` | broken. `doctor` exits `1` if any row is `✗`, so it can be run from a monitoring script; a `⚠` never causes that |
|
||||
|
||||
The distinction on the last row is worth spelling out: **shard not running** is a `⚠` (start it),
|
||||
while **shard running and not dialed in** is a `✗` — that is the silent failure this whole guide
|
||||
warns about, where ServUO reports a clean boot over a script build that failed.
|
||||
|
||||
### `runicgateway update`
|
||||
|
||||
Re-resolves the bundle and moves both halves to a combination whose protocol versions were checked
|
||||
together — never to two independently-latest artifacts that may disagree.
|
||||
|
||||
- **Sidecar**: download → verify → replace binary → restart service. No shard downtime.
|
||||
- **Overlay**: download → verify → re-sync → record the new commit → **tell you to restart ServUO.**
|
||||
It does not restart your shard.
|
||||
|
||||
Your `sidecar.toml`, your `Bridge.cfg` edits and your database are not touched. `Bridge.cfg` is
|
||||
overwritten only if you have not changed it; a modified copy is reported, not clobbered.
|
||||
|
||||
**Anything it does overwrite is copied first.** Every `.cs` file the overlay owns is replaced
|
||||
unconditionally — that is deliberate, they are code — so if you have edited one, the run saves your
|
||||
copy under `backups/<timestamp>/` in the state directory before writing, alongside `sidecar.toml`
|
||||
and any stock ServUO file the patch tier is about to touch. Each backup carries a `manifest.json`
|
||||
saying where every file came from. The newest three are kept; `--no-backup` skips taking one.
|
||||
|
||||
Putting a file back is yours to do — the installer will not restore an old file over a newer
|
||||
release, because it cannot know what has changed since. A run that overwrites nothing takes no
|
||||
backup, so a no-op `update` leaves nothing behind.
|
||||
|
||||
It updates the ServUO tree `install.json` names — not a tree it detects — and it needs the shard
|
||||
stopped, exactly as `install` does. There is nothing to update on a host that was never installed;
|
||||
it says so rather than performing a first install under a verb that promises to preserve.
|
||||
|
||||
**Your auth token is not reprinted.** It has not changed and your website already has it. The one
|
||||
thing an update can change that the site must be told about is the **protocol version**, and it says
|
||||
so plainly when that happens — a stale number in Admin → Shard is answered with `409` and looks
|
||||
exactly like your shard going offline.
|
||||
|
||||
**The patch tier under `update`:** features you already have are re-checked against the new release
|
||||
(normally nothing to do), without asking you again — you consented when they were installed, and
|
||||
that includes a shard where the tier ran unsupported. Features you never took are **named, not
|
||||
applied**; run `update --patches` (or `install --patches`) to take one up. A shard that declined the
|
||||
tier stays unpatched through every update.
|
||||
|
||||
### `runicgateway uninstall`
|
||||
|
||||
Removes what it exclusively owns, and **prints** everything else. The installer cannot know what you
|
||||
have changed in your own server tree since deployment, so an automatic revert risks silently eating
|
||||
your work.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Removed** | The sidecar binary, its service entry, `install.json` |
|
||||
| **Kept** | `sidecar.toml`, `uo-link.db`, the cached patch set with its pre-patch originals, and every backup an upgrade took (`--purge` drops all of them) |
|
||||
| **Printed, not done** | Every overlay file deployed into your ServUO tree, by path, for you to delete — with any file you have edited since deployment flagged, so you do not delete your own work by mistake |
|
||||
| **Printed, not done** | The exact hunks each applied patch added to `EventSink.cs`, `PlayerVendorGumps.cs` and `Logging.cs`, for you to revert — with how each landed, since one placed into a file you had already modified is worth a closer look. The pre-patch copy kept under `patches/originals/` is there to diff against. |
|
||||
|
||||
It lists all of that **before** asking, and the prompt defaults to **no**. `--yes` proceeds, which is
|
||||
what an unattended uninstall needs; nothing else about the command is destructive to your shard,
|
||||
which is neither stopped nor started.
|
||||
|
||||
The report is also written to a file — `runicgateway-uninstall-<timestamp>.txt` in the directory you
|
||||
ran the command from — so it survives the scrollback. That is why the cached patches and the
|
||||
originals stay behind by default: they are the only offline record of what the tier changed once the
|
||||
release tarball is gone, and the report tells you to diff against them.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause and fix |
|
||||
|---|---|
|
||||
| **Windows asks for Administrator as soon as you launch it** | Expected, and it needs Administrator anyway. Windows applies *installer detection* to unsigned executables whose file name contains `install` and elevates them before the program starts. Run it from an already-elevated PowerShell and you will not see the prompt. |
|
||||
| **"ServUO is running — stop it before installing"** | Correct, and not overridable. `ServUO.exe` locks `Scripts.dll` and rewrites `Saves/` on exit; deploying underneath it corrupts one or both. Stop the shard, install, start it again. |
|
||||
| Shard boots clean but nothing reaches the site | The classic silent failure: ServUO ignores the script build's exit code and reloaded a **stale `Scripts.dll`**. Run `dotnet build Scripts/Scripts.csproj -c Release -p:Platform=x64` and read the errors it prints. |
|
||||
| `[bridge status` says `connected=False` | The sidecar is not listening on `127.0.0.1:7788`. Check the service is running, and that `[shard] bind` in `sidecar.toml` matches `Host`/`Port` in `Bridge.cfg`. |
|
||||
| `[bridge` is not a command | The plugin did not compile, or `Bridge.cfg` has the bridge disabled. See the row above. |
|
||||
| Website says the shard is offline; `/health` is fine locally | The website cannot reach port 8080 — bind address or firewall. See [§5](#if-your-website-is-on-a-different-machine). Note that the site is *designed* to render normally with the shard offline, so this fails quietly by design. |
|
||||
| Website logs `409` from the sidecar | Protocol mismatch: the number in Admin → Shard does not match the sidecar's. The sidecar rejects rather than mis-parsing. Set the field to what `/health` reports (`protocol`). If the *sidecar* and *overlay* disagree, you have a hand-assembled pair — reinstall from a bundle. |
|
||||
| `401` from the sidecar | Wrong or missing auth token. Read the live one back with `uo-link-sidecar --print-config --config <path>`; do not retype it from a screenshot. |
|
||||
| **"service NOT REGISTERED" at the end of an otherwise successful run** | The host has no service manager the installer can drive — most often no systemd (a container, or a distro that never had it), or the `runicgateway` user could not be created. The binary and config *are* installed; the run prints the exact unit and commands to finish by hand. It never falls back to running the service as root or `LocalSystem`. |
|
||||
| **Windows: `sc start` fails with 1053, "the service did not respond in a timely fashion"** | Almost always a **sidecar older than v1.2.0**, which cannot start as a service no matter how correct its config. 1053 is a handshake failure, not a crash: Windows waited 30 seconds for the process to identify itself to the service control manager, and a sidecar built before service support was added never does. Check with `"C:\Program Files\RunicGateway\uo-link-sidecar.exe" --version`. Tell-tale signs: `sc query` shows `SERVICE_EXIT_CODE : 0` (nothing crashed), and running the same binary in the foreground with the same `--config` works perfectly. |
|
||||
| Service registered but stops immediately | Distinct from 1053 above — here the process really did exit. On Windows read `%ProgramData%\RunicGateway\uo-link-sidecar.<date>.log`, which is where a service logs since it has no stdout, and check that `sc qc RunicGatewayLink` shows `--config` in `BINARY_PATH_NAME` and that `NT SERVICE\RunicGatewayLink` has read access to `sidecar.toml`; on Linux check the `runicgateway` user can read `/etc/runicgateway/sidecar.toml` and write `/var/lib/runicgateway/`, and read `journalctl -u runicgateway-link`. |
|
||||
| A patch will not apply | Expected on a hand-modified shard. The base install is unaffected; you lose only the two features in [§4](#4-the-patch-tier-optional). Apply the hunks by hand if you want them. |
|
||||
| `vendor.sale` events never arrive despite patching | The `EventSink.cs` patch is a **core** change. A shard restart is not enough — rebuild the solution (`dotnet build ServUO.sln`). |
|
||||
| Sidecar writes its database somewhere unexpected | A relative `[store] path` resolves against the directory holding `sidecar.toml` — not the working directory. Run `--print-config` to see the absolute path it will actually use. |
|
||||
| Token leaked into a log or a screenshot | Clear `[web] auth_token` in `sidecar.toml`, restart the service (a new token is generated and saved), read it back with `--print-config`, and re-save it in Admin → Shard. |
|
||||
|
||||
---
|
||||
|
||||
## Appendix A — installing by hand
|
||||
|
||||
This is what the installer automates, done by hand. It is a **supported path**, not a deprecated
|
||||
one — reach for it when the host cannot run the binary, when you would rather not run an unsigned
|
||||
one, when you want to place every file yourself, or when you are developing on the bridge and
|
||||
installing from a working tree instead of a release. It is also the reference for what
|
||||
[§2](#2-run-it) does under the hood.
|
||||
|
||||
For a normal shard, [the installer](#1-download-and-verify) is fewer steps and checks more.
|
||||
|
||||
Throughout: `<servuo>` is your ServUO root, and **the shard is stopped**.
|
||||
|
||||
### A1. Fetch the bundle (so you install a checked pair)
|
||||
|
||||
```bash
|
||||
curl -s https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/bundles/current.json
|
||||
```
|
||||
|
||||
It names the sidecar tag, the overlay tag, their agreed `protocol`, and the SHA256 of every asset.
|
||||
Use those versions together; that pairing is the only thing CI has verified.
|
||||
|
||||
### A2. Deploy the plugin overlay
|
||||
|
||||
```bash
|
||||
curl -LO https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/releases/download/v0.1.1/runicgateway-overlay-0.1.1.tar.gz
|
||||
curl -LO https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/releases/download/v0.1.1/SHA256SUMS
|
||||
sha256sum -c SHA256SUMS --ignore-missing # must say: OK
|
||||
|
||||
tar xzf runicgateway-overlay-0.1.1.tar.gz # → runicgateway-overlay/
|
||||
cd runicgateway-overlay
|
||||
cat manifest.json # version, commit, protocol, per-file hashes
|
||||
|
||||
cp -r overlay/. <servuo>/ # adds files; overwrites Scripts/Scripts.csproj
|
||||
```
|
||||
|
||||
On Windows, `Expand-Archive` does not read `.tar.gz`; use `tar.exe` (shipped with Windows 10+) and
|
||||
`Copy-Item -Recurse -Force`. Plugin developers have `deploy.ps1` in the source repo, which does the
|
||||
same copy with a hash diff and a `-Verify` dry run — it is not shipped in the tarball.
|
||||
|
||||
The overlay only ever **adds or overwrites**. Nothing in your tree is deleted.
|
||||
|
||||
*Optional — the patch tier* (stock ServUO 57.4 only; see `patches/README.md` in the tarball for the
|
||||
full explanation):
|
||||
|
||||
```bash
|
||||
cd <servuo>
|
||||
git apply --check patches/playervendor-sale-eventsink.patch patches/playervendor-sale-gump.patch
|
||||
git apply patches/playervendor-sale-eventsink.patch patches/playervendor-sale-gump.patch
|
||||
cp patches/BridgeVendorSale.cs Scripts/Custom/Bridge/
|
||||
dotnet build ServUO.sln # REQUIRED — EventSink.cs is a core file
|
||||
|
||||
git apply --check patches/commandlogging-event.patch
|
||||
git apply patches/commandlogging-event.patch
|
||||
cp patches/BridgeModerationAudit.cs Scripts/Custom/Bridge/
|
||||
```
|
||||
|
||||
`git apply` works in a plain directory — the shard does not need to be a git repo. If you use
|
||||
`patch` instead, note that some core files are CRLF while others are LF: use `patch --binary`.
|
||||
|
||||
**If `git apply` refuses a patch whose target region is visibly untouched, line endings are the
|
||||
usual cause** — the `.patch` files and their targets do not all use the same ones, and `git apply`
|
||||
compares them literally. The installer's own tier normalizes line endings and trailing whitespace
|
||||
for the *comparison* while writing back your file's own endings, which is why it can place patches
|
||||
`git apply` rejects. Running the installer is the easier route here.
|
||||
|
||||
### A3. Install the sidecar
|
||||
|
||||
On an arm64 host substitute `uo-link-sidecar-linux-aarch64` for the asset name below (`uname -m`
|
||||
says `aarch64`); releases from v1.2.0 carry both. Take the version from the bundle you fetched in
|
||||
A1 rather than the one written here.
|
||||
|
||||
```bash
|
||||
curl -LO https://gitea.whitlocktech.com/RunicGateway/link/releases/download/v1.1.0/uo-link-sidecar-linux-x86_64
|
||||
curl -LO https://gitea.whitlocktech.com/RunicGateway/link/releases/download/v1.1.0/SHA256SUMS
|
||||
sha256sum -c SHA256SUMS --ignore-missing
|
||||
|
||||
sudo install -m 0755 uo-link-sidecar-linux-x86_64 /usr/bin/runicgateway-link
|
||||
sudo mkdir -p /etc/runicgateway /var/lib/runicgateway
|
||||
```
|
||||
|
||||
Provision the config and read back the token in one step. `--print-config` writes the file if it is
|
||||
missing, generates the auth token if there is none, and prints the resolved settings as JSON — it is
|
||||
the supported alternative to scraping the startup log:
|
||||
|
||||
```bash
|
||||
sudo UOLINK_DB_PATH=/var/lib/runicgateway/uo-link.db \
|
||||
/usr/bin/runicgateway-link --print-config --config /etc/runicgateway/sidecar.toml
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"component": "uo-link-sidecar",
|
||||
"version": "1.1.0",
|
||||
"protocol": 3,
|
||||
"config_path": "/etc/runicgateway/sidecar.toml",
|
||||
"config_created": true,
|
||||
"token_generated": true,
|
||||
"shard": { "bind": "127.0.0.1:7788" },
|
||||
"web": {
|
||||
"bind": "127.0.0.1:8080",
|
||||
"ws_path": "/ws",
|
||||
"auth_required": true,
|
||||
"auth_token": "4f9c…"
|
||||
},
|
||||
"store": { "path": "/var/lib/runicgateway/uo-link.db" }
|
||||
}
|
||||
```
|
||||
|
||||
`config_created` and `token_generated` tell you whether *this* run provisioned anything — the values
|
||||
alone cannot distinguish a fresh install from a re-read. **The output contains the auth token in
|
||||
clear text**: keep it out of shell transcripts, logs and support bundles.
|
||||
|
||||
### A4. Register the service
|
||||
|
||||
**Linux** — `/etc/systemd/system/runicgateway-link.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Runic Gateway uo-link sidecar
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=runicgateway
|
||||
Environment=UOLINK_CONFIG=/etc/runicgateway/sidecar.toml
|
||||
Environment=UOLINK_DB_PATH=/var/lib/runicgateway/uo-link.db
|
||||
ExecStart=/usr/bin/runicgateway-link
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo useradd --system --no-create-home runicgateway
|
||||
sudo chown -R runicgateway /var/lib/runicgateway /etc/runicgateway
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now runicgateway-link
|
||||
systemctl status runicgateway-link
|
||||
```
|
||||
|
||||
**Windows** (elevated PowerShell) — binary under `%ProgramFiles%`, data under `%ProgramData%`:
|
||||
|
||||
```powershell
|
||||
New-Item -ItemType Directory -Force "$env:ProgramFiles\RunicGateway", "$env:ProgramData\RunicGateway" | Out-Null
|
||||
Copy-Item .\uo-link-sidecar-windows-x86_64.exe "$env:ProgramFiles\RunicGateway\uo-link-sidecar.exe"
|
||||
|
||||
& "$env:ProgramFiles\RunicGateway\uo-link-sidecar.exe" --print-config --config "$env:ProgramData\RunicGateway\sidecar.toml"
|
||||
|
||||
# The config file now holds your auth token. Lock it down before anything else can read it:
|
||||
icacls "$env:ProgramData\RunicGateway\sidecar.toml" /inheritance:r /grant:r '*S-1-5-18:(F)' /grant:r '*S-1-5-32-544:(F)'
|
||||
|
||||
# binPath carries the config path. The single quotes matter: the value itself contains the double
|
||||
# quotes the service manager needs around a path with spaces in it.
|
||||
sc.exe create RunicGatewayLink `
|
||||
binPath= '"C:\Program Files\RunicGateway\uo-link-sidecar.exe" --config "C:\ProgramData\RunicGateway\sidecar.toml"' `
|
||||
obj= 'NT SERVICE\RunicGatewayLink' start= auto
|
||||
sc.exe failure RunicGatewayLink reset= 86400 actions= restart/5000
|
||||
|
||||
# The service account exists only once sc create has created it, so its grants come after:
|
||||
icacls "$env:ProgramData\RunicGateway\sidecar.toml" /grant 'NT SERVICE\RunicGatewayLink:(R)'
|
||||
icacls "$env:ProgramData\RunicGateway" /grant 'NT SERVICE\RunicGatewayLink:(OI)(CI)M'
|
||||
|
||||
sc.exe start RunicGatewayLink
|
||||
```
|
||||
|
||||
Four things there are easy to get wrong:
|
||||
|
||||
- **The sidecar must be v1.2.0 or newer.** Earlier builds are plain console programs, and the
|
||||
Windows service control manager cannot supervise one: it waits 30 seconds for the process to
|
||||
identify itself, then fails the start with **1053** even though the process is running and healthy.
|
||||
From v1.2.0 the same binary does both — started by the SCM it runs as a service, started from a
|
||||
shell it runs in the foreground, with no flag to choose between them.
|
||||
- **The config path goes in `binPath`, not in a machine environment variable.** `sc.exe` has no
|
||||
per-service environment, and a machine-wide `UOLINK_CONFIG` would be inherited by every process on
|
||||
the host and survive an uninstall. Never leave the config path to the default — it is relative to
|
||||
the service's working directory, which for a service is `%SystemRoot%\System32`.
|
||||
- **The database needs no pinning here.** A relative `[store] path` resolves against the directory
|
||||
holding `sidecar.toml`, which is already `%ProgramData%\RunicGateway`.
|
||||
- **`obj=` is what keeps this off `LocalSystem`.** `NT SERVICE\RunicGatewayLink` is a virtual
|
||||
service account: Windows creates it with the service, it has no password, and it exists only for
|
||||
this service. Omit `obj=` and you get the most privileged local identity there is, for a process
|
||||
listening on two TCP ports.
|
||||
|
||||
Once it is running, `%ProgramData%\RunicGateway\uo-link-sidecar.<date>.log` is where it logs — a
|
||||
service has no console to write to. Seven days are kept.
|
||||
|
||||
### A5. Connect the website, start the shard, verify
|
||||
|
||||
Exactly as in [§5](#5-connect-the-website) and [§6](#6-start-servuo-and-verify): paste the four
|
||||
values into Admin → Shard, start ServUO, then check `[bridge status` in game and `/health` on the
|
||||
sidecar.
|
||||
|
||||
### A6. Updating by hand
|
||||
|
||||
Re-read `current.json`, and if either version moved: replace the sidecar binary and restart its
|
||||
service; re-extract the overlay tarball over your tree and restart ServUO. Keep the two in step —
|
||||
`current.json` is the only statement that a given pair speaks the same protocol.
|
||||
|
||||
---
|
||||
|
||||
## Appendix B — `sidecar.toml` reference
|
||||
|
||||
Written on first run with a generated token. Environment variables override the file; the file
|
||||
overrides these defaults.
|
||||
|
||||
```toml
|
||||
[shard]
|
||||
bind = "127.0.0.1:7788" # where the SHARD dials in. Keep this on loopback.
|
||||
|
||||
[web]
|
||||
bind = "127.0.0.1:8080" # where the WEBSITE connects. Widen only with a firewall in front.
|
||||
auth_token = "…" # generated if blank; the website's Admin → Shard "Auth token"
|
||||
|
||||
[store]
|
||||
path = "uo-link.db" # relative paths resolve against this file's directory, not the CWD
|
||||
```
|
||||
|
||||
| Environment variable | Overrides |
|
||||
|---|---|
|
||||
| `UOLINK_CONFIG` | Which config file to read (`--config <PATH>` outranks it) |
|
||||
| `UOLINK_SHARD_BIND` | `[shard] bind` |
|
||||
| `UOLINK_WEB_BIND` | `[web] bind` |
|
||||
| `UOLINK_WEB_TOKEN` | `[web] auth_token` |
|
||||
| `UOLINK_DB_PATH` | `[store] path` |
|
||||
|
||||
| Sidecar command | Output |
|
||||
|---|---|
|
||||
| `uo-link-sidecar --version` | `uo-link-sidecar 1.1.0 (protocol 3)` |
|
||||
| `uo-link-sidecar --print-config [--config PATH]` | The JSON in [A3](#a3-install-the-sidecar). Provisions on first run. **Contains the token.** |
|
||||
| `uo-link-sidecar --help` | Usage. An unrecognized argument exits `2` rather than starting a sidecar you did not ask for. |
|
||||
|
||||
Authentication is **always on**: a blank token is generated and written back, so the web surface is
|
||||
never unauthenticated. `/health` is the one unauthenticated route, so monitoring can reach it.
|
||||
|
||||
---
|
||||
|
||||
## Appendix C — `Config/Bridge.cfg` settings worth reviewing
|
||||
|
||||
The file is deployed heavily commented and every setting has a working default — you can leave it
|
||||
entirely alone. These are the ones most shards want to look at once. Run `[bridge reload` after
|
||||
editing; endpoint changes take effect on the next reconnect.
|
||||
|
||||
| Setting | Default | Why you might change it |
|
||||
|---|---|---|
|
||||
| `LinkUrl` | `https://yoursite/link` | Shown in game when a player runs `[link` to connect their account. **Set this to your site.** |
|
||||
| `PublicConnectAddress` | *(blank)* | The one connection detail the bridge will publish, e.g. `play.myshard.com,2593`. Blank omits it; `Server.cfg`'s address is **never** published automatically. |
|
||||
| `AdminWriteEnabled` | `false` | Opt-in staff write plane: kick/ban/broadcast from the website. Authorization is enforced on the website; `AdminAccessFloor` is the shard-side floor that even a compromised sidecar cannot cross. |
|
||||
| `MarketEnabled`, `MarketSweepSeconds`, `MarketSweepBatch` | `true`, `60`, `25` | The player-vendor index. Coverage takes `ceil(vendors / batch) × seconds` — 500 vendors is one full pass every 20 minutes at the defaults. |
|
||||
| `PointsLeaderboardEnabled`, `PointsTopN`, `PointsSystems` | `true`, `10`, *(all shown on the loyalty gump)* | Standings boards. One frame **per system**, and ServUO carries ~25 of them, so a large `TopN` multiplies. |
|
||||
| `RulesetEnabled`, `RulesetIncludeSchedule` | `true`, `true` | Publishes your ruleset (expansion, caps, systems on/off) to the site's rules page. Turn the schedule off if you would rather not advertise a predictable restart window. |
|
||||
| `SignupMode` | `hybrid` | Which side may mint accounts — `website`, `game`, or `hybrid`. Pair `website` with `Accounts.AutoCreateAccounts=false`, or an in-game login still creates accounts. |
|
||||
| `QueueCap` | `10000` | Outbound queue cap. On overflow the plugin **drops oldest** and counts drops, because a stalled sidecar must never take the shard down with it. |
|
||||
|
||||
Sweep intervals (`StatSweepSeconds`, `DecaySweepSeconds`, `EconomySweepSeconds`, and the rest) trade
|
||||
freshness against Core-thread time. The measured cost is small — a vitals sweep is 0.0015 ms per
|
||||
character, so 1000 online players is ~1.5 ms per pass — but there is rarely anything to gain by
|
||||
hurrying them.
|
||||
|
||||
---
|
||||
|
||||
## Where to go next
|
||||
|
||||
| Doc | What |
|
||||
|---|---|
|
||||
| [PLAN.md](PLAN.md) | The installer's design of record — phases, locked decisions, the bundle model |
|
||||
| [`bundles/README.md`](https://gitea.whitlocktech.com/RunicGateway/installer/src/branch/main/bundles/README.md) | The compat matrix: what a bundle is and how it is composed |
|
||||
| [link/INTEGRATION.md](../link/INTEGRATION.md) | The sidecar's HTTP/WS API — for anyone integrating something other than the website |
|
||||
| [link/ADMIN_CONTROLS.md](../link/ADMIN_CONTROLS.md) | The staff write plane in detail, before you turn `AdminWriteEnabled` on |
|
||||
| [website/SHARD_VISIBILITY.md](../website/SHARD_VISIBILITY.md) | Which shard data each audience sees, configured on the website |
|
||||
| [link/SHARD_PREREQS.md](../link/SHARD_PREREQS.md) | A worked example of diagnosing a shard whose scripts silently stopped compiling |
|
||||
1049
installer/PLAN.md
1049
installer/PLAN.md
File diff suppressed because it is too large
Load Diff
@@ -1,67 +0,0 @@
|
||||
# Runic Gateway installer — Project Tree
|
||||
|
||||
> **Auto-generated.** This file is maintained by the `sync-project-tree` CI workflow in
|
||||
> the [`RunicGateway/installer`](https://gitea.whitlocktech.com/RunicGateway/installer) repository, which
|
||||
> opens a pull request here whenever the tracked file layout on `main` changes. Do not edit
|
||||
> by hand — changes will be overwritten by the next sync.
|
||||
|
||||
A snapshot of the tracked files in the repository (build output, dependencies, and other
|
||||
git-ignored paths are excluded).
|
||||
|
||||
```text
|
||||
installer/
|
||||
├── .gitea/
|
||||
│ ├── ISSUE_TEMPLATE/
|
||||
│ │ ├── bug_report.md
|
||||
│ │ ├── config.yaml
|
||||
│ │ └── feature_request.md
|
||||
│ ├── scripts/
|
||||
│ │ └── gen_tree.py
|
||||
│ ├── workflows/
|
||||
│ │ ├── bundle.yml
|
||||
│ │ ├── pr-checks.yml
|
||||
│ │ ├── release.yml
|
||||
│ │ └── sync-project-tree.yml
|
||||
│ └── PULL_REQUEST_TEMPLATE.md
|
||||
├── bundles/
|
||||
│ └── README.md
|
||||
├── src/
|
||||
│ ├── backup.rs
|
||||
│ ├── bundle.rs
|
||||
│ ├── cli.rs
|
||||
│ ├── diff.rs
|
||||
│ ├── doctor.rs
|
||||
│ ├── install.rs
|
||||
│ ├── lib.rs
|
||||
│ ├── main.rs
|
||||
│ ├── net.rs
|
||||
│ ├── overlay.rs
|
||||
│ ├── patch.rs
|
||||
│ ├── paths.rs
|
||||
│ ├── record.rs
|
||||
│ ├── service.rs
|
||||
│ ├── servuo.rs
|
||||
│ ├── sidecar.rs
|
||||
│ ├── tier.rs
|
||||
│ ├── ui.rs
|
||||
│ ├── uninstall.rs
|
||||
│ ├── update.rs
|
||||
│ └── util.rs
|
||||
├── tests/
|
||||
│ ├── fixtures/
|
||||
│ │ ├── commandlogging-event.patch
|
||||
│ │ ├── patch_tier.json
|
||||
│ │ ├── playervendor-sale-eventsink.patch
|
||||
│ │ ├── playervendor-sale-gump.patch
|
||||
│ │ └── published-bundle.json
|
||||
│ └── real_patches.rs
|
||||
├── .gitignore
|
||||
├── Cargo.lock
|
||||
├── Cargo.toml
|
||||
├── CODE_OF_CONDUCT.md
|
||||
├── CONTRIBUTING.md
|
||||
├── CONTRIBUTORS.md
|
||||
├── LICENSE.md
|
||||
├── README.md
|
||||
└── SECURITY.md
|
||||
```
|
||||
@@ -23,31 +23,7 @@ Every route **except `GET /health`** requires the shared token from `sidecar.tom
|
||||
| REST | `X-Api-Key: <token>` |
|
||||
| WebSocket | `?token=<token>` in the connect URL (browsers can't set headers on a WS handshake) |
|
||||
|
||||
Missing or wrong token → **401** `{"error":"missing or invalid auth token"}`. The token is compared in constant time. It is generated automatically on first run; rotate by editing `sidecar.toml` and restarting.
|
||||
|
||||
To read it back afterwards, ask the sidecar rather than hunting through the startup log or the TOML:
|
||||
|
||||
```console
|
||||
$ uo-link-sidecar --print-config --config /etc/runicgateway/sidecar.toml
|
||||
{
|
||||
"component": "uo-link-sidecar",
|
||||
"config_created": false,
|
||||
"config_path": "/etc/runicgateway/sidecar.toml",
|
||||
"protocol": 3,
|
||||
"shard": { "bind": "127.0.0.1:7788" },
|
||||
"store": { "path": "/var/lib/runicgateway/uo-link.db" },
|
||||
"token_generated": false,
|
||||
"version": "0.1.0",
|
||||
"web": {
|
||||
"auth_required": true,
|
||||
"auth_token": "c0f04ace…",
|
||||
"bind": "127.0.0.1:8080",
|
||||
"ws_path": "/ws"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
That is the same set of values Admin → Shard asks for — base URL and WS URL are `web.bind` (substituting a reachable host if it is `0.0.0.0`) plus `web.ws_path`. The output **contains the token in clear text**, so treat it as a secret: it belongs in a terminal, not in a log or a CI artifact. `--print-config` also performs first-run setup, writing the config file and generating a token if there is none, and reports whether it did via `config_created` / `token_generated`.
|
||||
Missing or wrong token → **401** `{"error":"missing or invalid auth token"}`. The token is compared in constant time. It is generated automatically on first run (the sidecar logs it); rotate by editing `sidecar.toml` and restarting.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -25,16 +25,12 @@ link/
|
||||
│ └── PULL_REQUEST_TEMPLATE.md
|
||||
├── sidecar/
|
||||
│ ├── src/
|
||||
│ │ ├── app.rs
|
||||
│ │ ├── cli.rs
|
||||
│ │ ├── config.rs
|
||||
│ │ ├── main.rs
|
||||
│ │ ├── rpc.rs
|
||||
│ │ ├── shard.rs
|
||||
│ │ ├── store.rs
|
||||
│ │ ├── unix.rs
|
||||
│ │ ├── web.rs
|
||||
│ │ └── windows.rs
|
||||
│ │ └── web.rs
|
||||
│ ├── .gitignore
|
||||
│ ├── Cargo.lock
|
||||
│ ├── Cargo.toml
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
# uo-link
|
||||
|
||||
> **Historical snapshot**, from before the bridge was split into
|
||||
> [`RunicGateway/link`](https://gitea.whitlocktech.com/RunicGateway/link) (sidecar) and
|
||||
> [`RunicGateway/servuo-plugins`](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins)
|
||||
> (plugin). Kept for the architecture notes below. **To set a shard up, use
|
||||
> [installer/INSTALL.md](../installer/INSTALL.md)** — `deploy.ps1` as described here is a developer
|
||||
> tool, not the operator path.
|
||||
|
||||
ServUO ⇄ Rust sidecar bridge. The shard emits newline-delimited JSON over a loopback TCP socket; the sidecar owns the WebSocket the website consumes.
|
||||
|
||||
```
|
||||
|
||||
@@ -1,539 +0,0 @@
|
||||
# Admin-Configurable Theming & Navigation
|
||||
|
||||
> Build contract for runtime-configurable theme, brand assets, and navigation.
|
||||
> Derived from the design doc *Spec: Admin-Configurable Theming & Navigation*,
|
||||
> **corrected to match the current codebase** and with the open questions resolved.
|
||||
> Same workflow as the hero editor: design → phased build → verify.
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Let the site admin customize, at runtime with no rebuild or redeploy:
|
||||
|
||||
1. **Visual theme** — colors, fonts (from a curated Google Fonts shortlist), and
|
||||
corner radius / shadow depth — via three presets or per-group custom overrides.
|
||||
2. **Brand assets** — logo, hero image, favicon — uploaded to override the
|
||||
`BRAND_*` env defaults.
|
||||
3. **Navigation** — reorder, relabel, and show/hide items in the public site nav,
|
||||
admin sidebar, and player portal nav, via drag-and-drop.
|
||||
|
||||
All three follow the `settings.model.js` pattern already used for `hero_layout`:
|
||||
a JSON value stored under a settings key, exposed through `getPublic()` where
|
||||
needed, edited from an admin view, applied at runtime.
|
||||
|
||||
## 2. Core principle: `BRAND_*` env stays the default, always
|
||||
|
||||
[`server/src/config/brand.js`](../../website/server/src/config/brand.js) is the
|
||||
existing single source of instance identity, read once at startup from env with
|
||||
baked-in Runic Gateway defaults. The app ships as one prebuilt image and each
|
||||
instance re-skins itself via env. **This feature must not disturb that.**
|
||||
|
||||
Every new setting is an *override layer*, never a replacement:
|
||||
|
||||
- An instance where the admin has not touched these settings renders
|
||||
**identically to today**, driven entirely by `BRAND_*` and the current
|
||||
`theme.css` `:root`.
|
||||
- Saving one setting makes that setting — and only that setting — take
|
||||
precedence. Untouched settings keep following env.
|
||||
- This holds **per field**, not per feature. A custom accent with untouched
|
||||
fonts means the accent comes from the DB and the fonts still come from
|
||||
`--serif`/`--display`/`--sans` as `theme.css` defines them.
|
||||
- "Admin-set" means **a DB row exists for that key**. Absence of the row — not an
|
||||
empty or false value — is what triggers the env/CSS fallback. An admin who
|
||||
explicitly picks a preset that happens to equal the shipped default has still
|
||||
set it, and it is stored and honored as explicit.
|
||||
- **No migration writes defaults into the settings table.** New and existing
|
||||
installs both start with zero rows for these keys; that absence *is* the
|
||||
"use env default" state.
|
||||
|
||||
## 3. Locked decisions
|
||||
|
||||
| # | Decision |
|
||||
|---|---|
|
||||
| Brand contract | **`getPublic().brand` returns effective values** (override → env). The Android app and Discord embeds track admin theming for free — see §4.5 |
|
||||
| Structural tokens | **Radius + shadow depth only.** `spacingUnit` and `borderWeight` are **cut**, not deferred — see §4.6 |
|
||||
| Radius token values | **Seeded at today's real values** (four tokens, not three), so the promotion step is a true no-op — see §4.7 |
|
||||
| Presets in v1 | **Three dark presets** — Runic Gateway, Modern, Fantasy. Parchment (light) is Phase 9 — see §4.8 |
|
||||
| Fonts | **Curated shortlist, dropdown-only**, 4 options per role, 8 web families in **one** `css2?` request — see §5 |
|
||||
| Raw custom CSS | **Out of scope entirely** — not deferred. Materially different risk profile (overlay/clickjacking tricks, tracking pixels via `background: url(...)`); would need its own feature and its own review |
|
||||
| Live preview | Out of scope for v1 |
|
||||
| Reduced-motion toggle | Out of scope for v1 |
|
||||
| Nav override power | **`label`, `order`, `hidden`, and (admin nav only) `group`.** Never `to`, `roles`, or `feature` — see §7 |
|
||||
| Reset to defaults | **Deletes the settings row.** Never writes a stored copy of the defaults |
|
||||
| Favicon uploads | **PNG only.** No `.ico` — see §4.10 |
|
||||
|
||||
## 4. Corrections to the design doc (current-code reality)
|
||||
|
||||
The design doc is structurally sound; the token architecture, the
|
||||
override-on-top-of-env principle, the nav-override security framing, and the
|
||||
reuse of `imageUpload.js` all match reality. These are the points where it does
|
||||
not, listed worst-first. §4.1–4.5 are blocking; §4.6–4.11 are scope corrections.
|
||||
|
||||
### 4.1 There is no way to delete a setting
|
||||
|
||||
The entire "Reset to defaults deletes the row" principle — which all five new
|
||||
keys rely on, and which the doc lists as an acceptance criterion — has no
|
||||
implementation.
|
||||
[`settings.db.js`](../../website/server/src/model/settings/settings.db.js)
|
||||
exposes `get` / `getAll` / `set` / `seedDefault` only, and the admin API is
|
||||
`PUT /admin/settings` taking a key/value object
|
||||
([`admin.controller.js:499`](../../website/server/src/router/v1/admin/admin.controller.js)).
|
||||
|
||||
**Fix:** add `settingsDb.remove(key)` and a `DELETE /api/v1/admin/settings/:key`
|
||||
route with an explicit key allowlist (the five new keys plus `hero_layout_draft`).
|
||||
Admin-only, same gate as the existing settings routes. Deleting a key that does
|
||||
not exist is a success, not a 404 — "reset" is idempotent.
|
||||
|
||||
### 4.2 Non-admins cannot read their own nav overrides
|
||||
|
||||
The doc says `nav_admin` / `nav_player` are admin-only settings "fetched by the
|
||||
authenticated `AdminLayout` / `PlayerPortalLayout`." But `GET /admin/settings` is
|
||||
gated `requireRole('admin')`
|
||||
([`settings.router.js:18,28`](../../website/server/src/router/v1/admin/settings.router.js)),
|
||||
while `AdminLayout` renders for **editors and moderators** and
|
||||
`PlayerPortalLayout` renders for **players**. Those users have no endpoint from
|
||||
which to read the key, so their nav would silently never apply the override.
|
||||
|
||||
**Fix:** new `GET /api/v1/settings/nav`, `isLoggedIn` only, returning
|
||||
`{ nav_admin, nav_player }`. Not in `PUBLIC_KEYS` — an anonymous visitor has no
|
||||
use for either, and the admin nav's labels leak the shape of the admin surface.
|
||||
|
||||
### 4.3 `renderIndexHtml` runs once at boot, not per request
|
||||
|
||||
[`app.js:207`](../../website/server/src/app.js) reads and templates `index.html`
|
||||
at module load and serves that one string for every SPA route forever. The doc
|
||||
describes overriding `logo`/`favicon` as "an async settings read inside a
|
||||
currently-synchronous-feeling builder" — it is actually a lifecycle change, not
|
||||
just an `await`.
|
||||
|
||||
**Fix:** keep the rendered shell cached in a module-level variable, render it
|
||||
lazily on first request, and invalidate on any successful write to
|
||||
`brand_assets`. Two hard requirements:
|
||||
|
||||
- A DB fault must never fail the page — on a read error, fall back to the
|
||||
env-only shell (the current behavior).
|
||||
- The shell must stay a single cached string in the steady state. Do not do a
|
||||
settings read per page view.
|
||||
|
||||
### 4.4 Settings values are strings, not objects
|
||||
|
||||
`settings.value` is `TEXT`
|
||||
([`schema.sql:126`](../../website/server/db/schema.sql)) and JSON-valued keys are
|
||||
stored `JSON.stringify`'d and parsed client-side — see `parseLayout` in
|
||||
[`heroLayout.js:58`](../../website/client/src/lib/heroLayout.js). The doc's
|
||||
`settings.brand_assets?.hero` and `settings.nav_public` read as if they arrive
|
||||
parsed. They do not.
|
||||
|
||||
**Fix:** one shared `parseJsonSetting(str, validator)` helper, used by every
|
||||
consumer. A malformed or wrong-shaped value is treated as **absent** (falls back
|
||||
to env/code default), never as an error and never as a partial object. This is
|
||||
the same fail-safe posture `parseLayout` already takes.
|
||||
|
||||
### 4.5 The Android app and Discord embeds are silently excluded
|
||||
|
||||
`getPublic().brand` is a **documented cross-repo contract**, not an internal
|
||||
detail. [`publicBrand.test.js:30`](../../website/server/test/publicBrand.test.js)
|
||||
locks its field list, and the Android app's `BrandDto` seeds the entire Material
|
||||
theme from `brand.accent` (`MainActivity.kt:72` → `RunicGatewayTheme`), with
|
||||
`logo` / `hero` / `favicon` fields alongside it. `brand.accentInt` — derived once
|
||||
at boot — is what Discord embeds color themselves with.
|
||||
|
||||
If theme and asset overrides live only in the new keys, an admin changes the
|
||||
accent on the website and **the phone app and the Discord bot keep the old one**.
|
||||
|
||||
**Fix (locked):** resolve the *effective* values server-side in
|
||||
`getPublic()`'s brand block
|
||||
([`settings.model.js:130-141`](../../website/server/src/model/settings/settings.model.js)):
|
||||
|
||||
```js
|
||||
accent: themeVisual?.colors?.accent ?? brand.accent
|
||||
logo: brandAssets?.logo ?? brand.logo
|
||||
hero: brandAssets?.hero ?? brand.hero
|
||||
favicon: brandAssets?.favicon ?? brand.favicon
|
||||
```
|
||||
|
||||
The web client needs **no change** for this — its existing
|
||||
`setProperty('--accent', brand.accent)` line
|
||||
([`SiteContext.jsx:30-32`](../../website/client/src/contexts/SiteContext.jsx))
|
||||
simply receives a better value. Consequences to handle:
|
||||
|
||||
- `brand.accentInt` must be **recomputed from the effective accent** per request
|
||||
rather than read from the boot-time constant, or Discord embeds drift.
|
||||
- `publicBrand.test.js` gains cases: no rows → env values unchanged (the existing
|
||||
assertions must still pass verbatim); `theme_visual` accent set → effective
|
||||
accent returned; `brand_assets.favicon` set → favicon overridden while `logo`
|
||||
and `hero` still come from env.
|
||||
- The Android app needs **no change** to pick up accent/assets. Whether it should
|
||||
also honor the full preset (radius, fonts) is a separate question for
|
||||
`docs/android/PLAN.md`, out of scope here.
|
||||
|
||||
### 4.6 `spacingUnit` and `borderWeight` are not variable renames
|
||||
|
||||
The doc treats these as the same mechanism as color. They are not:
|
||||
|
||||
- **Spacing.** `theme.css` contains **zero** `calc()`-based spacings (the 5
|
||||
`calc()` uses are all `width: min(…, calc(100% - 32px))` page shells). Every
|
||||
padding is a hand-written non-multiple — `7px 14px`, `12px 26px`, `11px 14px`,
|
||||
`13px 14px`. A density token that actually moves density means rewriting ~40
|
||||
declarations into `calc(var(--space-unit) * n)`, and most of the app's real
|
||||
spacing is inline JSX the token cannot reach anyway.
|
||||
- **Border weight.** 39 hand-written `1px` borders, several of which are
|
||||
*semantic* accents that must not scale with a density slider — `.note`'s 3px
|
||||
left rule, `.page-quote`'s 3px, `.pb-tab`'s 2px active underline.
|
||||
|
||||
**Decision:** both are **cut from v1** and do not appear in the admin form.
|
||||
Colors, fonts, radius and shadow depth cover "brand feel" cleanly; these two do
|
||||
not, and shipping them as no-op fields would be worse than not shipping them.
|
||||
|
||||
### 4.7 Six radii cannot round-trip through three tokens
|
||||
|
||||
The doc's preset blocks set `--radius-card: 8px`, but the actual values in
|
||||
`theme.css` are 14×`8px`, 4×`999px`, 4×`10px`, 1×`12px`, 1×`7px`, 1×`6px`. `.card`
|
||||
and `.panel` are **10px** today and `.panel-flat` is **12px**. Adopting the doc's
|
||||
three tokens verbatim would restyle every existing instance — including ones that
|
||||
never touch the feature — which contradicts the acceptance criterion directly
|
||||
above it.
|
||||
|
||||
**Fix (locked):** four tokens seeded at today's real values, so the promotion step
|
||||
is genuinely a no-op:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--radius-pill: 999px; /* .btn, .pill, .badge, .wiki-tag */
|
||||
--radius-panel: 12px; /* .panel-flat */
|
||||
--radius-card: 10px; /* .card, .panel */
|
||||
--radius-input: 8px; /* .input, .textarea, .select, .btn-sq, .note, .rte, .prose img */
|
||||
}
|
||||
```
|
||||
|
||||
The 7px (`.rte-btn`) and 6px (`.rte-linkmenu-item`) values stay literals — they are
|
||||
interior editor chrome, not brand surface. The preset blocks in §6 carry corrected
|
||||
`--radius-card` values accordingly.
|
||||
|
||||
### 4.8 Parchment is a light-mode port, not a preset
|
||||
|
||||
`theme.css` carries 28 `rgba()` literals that assume a dark background — `.pill`'s
|
||||
`rgba(11,22,48,0.5)` fill, `.note`'s background, all seven `.badge-*` fills, the
|
||||
diff add/del colors, `.moon`'s radial gradient, `#dbe2ea` prose strong — plus the
|
||||
hero overlay stacks `rgba(11,15,20,…)` hardcoded in `heroLayout.js` and four route
|
||||
files, plus `rgba(9,13,18,0.86)` inline in `SiteHeader.jsx:55`. None of that
|
||||
responds to a `[data-theme]` variable block; Parchment would inherit dark chrome
|
||||
on a light background and look broken.
|
||||
|
||||
**Decision:** three dark presets in v1. Parchment becomes **Phase 9**, scoped as a
|
||||
light-mode port with its own contrast pass across every component.
|
||||
|
||||
### 4.9 The hero already has a third override layer
|
||||
|
||||
`hero_layout.background.image_url` **already** beats `brand.hero`
|
||||
([`heroLayout.js:39-50`](../../website/client/src/lib/heroLayout.js)). The real
|
||||
resolution order is:
|
||||
|
||||
```
|
||||
hero_layout.background.image_url → brand_assets.hero → BRAND_HERO → /assets/img/runic-emblem.png
|
||||
```
|
||||
|
||||
The doc's two-link chain omits the existing top link. The admin UI must say so
|
||||
explicitly, or "I uploaded a hero and the portal ignored it" becomes a bug report
|
||||
against a working system.
|
||||
|
||||
### 4.10 Favicon `.ico` is not possible without weakening the upload path
|
||||
|
||||
`MIME_EXT` in
|
||||
[`imageUpload.js:24-30`](../../website/server/src/router/v1/admin/imageUpload.js)
|
||||
has no `image/x-icon` or `image/vnd.microsoft.icon` entry, and the stored
|
||||
extension is derived from that map — which is exactly the property that makes the
|
||||
upload path safe. The doc floats "`.ico`/`.png` only" for favicons; the `.ico`
|
||||
half would mean adding a new file type to `/uploads`.
|
||||
|
||||
**Decision:** **PNG only** for favicons. `<link rel="icon">` accepts PNG in every
|
||||
browser this app supports, and the allowlist is left untouched. A tighter size cap
|
||||
than the shared 8 MB limit is applied at the route, not in the shared multer
|
||||
config.
|
||||
|
||||
### 4.11 Smaller notes
|
||||
|
||||
- **CSP is already fine.** [`config/csp.js:50-51`](../../website/server/src/config/csp.js)
|
||||
already allows `https://fonts.googleapis.com` in `style-src` and
|
||||
`https://fonts.gstatic.com` in `font-src`. The font shortlist needs no CSP
|
||||
change — which is worth stating, because widening CSP for a cosmetic feature
|
||||
would not be worth it.
|
||||
- **Do not touch the footer badge.**
|
||||
[`SiteFooter.jsx:19`](../../website/client/src/components/SiteFooter.jsx) is the
|
||||
hardcoded "powered by Runic Gateway" emblem. It is deliberately not the instance
|
||||
logo and must not follow `brand_assets.logo`.
|
||||
- **Nav labels do not reach the portal hero.** `hero_layout`'s
|
||||
`default-quick-links` element duplicates News / Screenshots / Five on Friday /
|
||||
Newsletter / About as its own buttons. Renaming those in the nav editor will not
|
||||
rename them on the portal; they are edited in the hero editor.
|
||||
- **The nav editor must refuse to hide its own entry.** Not a lockout — hiding is
|
||||
presentation-only and the URL still resolves — but recovering by typing a URL is
|
||||
a bad enough experience to be worth one guard.
|
||||
- **Process, per `CLAUDE.md`.** Every server-side phase requires
|
||||
`npm run swagger`, `npm run routes:manifest` (`routeManifest.test.js` fails
|
||||
otherwise), and a matching edit to
|
||||
[`BACKEND_DESIGN.md`](BACKEND_DESIGN.md). None of this is in the design doc.
|
||||
|
||||
## 5. Fonts: curated Google Fonts, not free text
|
||||
|
||||
`index.html` already loads Cinzel from Google Fonts, so this extends an existing,
|
||||
already-trusted pattern rather than introducing a new one.
|
||||
|
||||
**The dropdown's value — not free text — is what is stored.** Each option's value
|
||||
*is* the full CSS `font-family` stack exactly as it will be applied, so the client
|
||||
does zero string-building from admin input and `theme_visual` stays a closed set of
|
||||
known-safe values.
|
||||
|
||||
### 5.1 The shortlist
|
||||
|
||||
| Role | Option | Stored stack |
|
||||
|---|---|---|
|
||||
| **Serif body** | EB Garamond — strongest fantasy/historic | `'EB Garamond', Georgia, serif` |
|
||||
| | Merriweather — excellent readability | `Merriweather, Georgia, serif` |
|
||||
| | Playfair Display — elegant/editorial | `'Playfair Display', Georgia, serif` |
|
||||
| | IM Fell English — strongest old-world/UO flavor | `'IM Fell English', Georgia, serif` |
|
||||
| **Display heading** | Cinzel — current Runic Gateway identity | `Cinzel, Georgia, serif` |
|
||||
| | Playfair Display — elegant alternative | `'Playfair Display', Georgia, serif` |
|
||||
| | EB Garamond — softer/classic | `'EB Garamond', Georgia, serif` |
|
||||
| | IM Fell English — very strong fantasy | `'IM Fell English', Georgia, serif` |
|
||||
| **Sans UI** | Inter — default modern UI choice | `Inter, Arial, sans-serif` |
|
||||
| | Work Sans — slightly more character | `'Work Sans', Arial, sans-serif` |
|
||||
| | Source Sans 3 — extremely readable | `'Source Sans 3', Arial, sans-serif` |
|
||||
| | Arial — safe fallback/system option | `'Helvetica Neue', Arial, sans-serif` |
|
||||
|
||||
Two properties fall out of this list and are worth keeping:
|
||||
|
||||
- **Arial is the zero-cost option** — its stack is byte-identical to today's
|
||||
`--sans`, so it needs no webfont at all and doubles as the current default.
|
||||
- **Twelve slots, eight web families.** Playfair Display, EB Garamond and IM Fell
|
||||
English each serve two roles.
|
||||
|
||||
### 5.2 Loading
|
||||
|
||||
One combined request, not eight — Google Fonts accepts multiple `family=`
|
||||
parameters per URL, and the font *binaries* are only fetched when a family is
|
||||
actually applied:
|
||||
|
||||
```html
|
||||
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600;700&family=EB+Garamond:ital,wght@0,400;0,600;0,700;1,400&family=IM+Fell+English:ital@0;1&family=Inter:wght@400;600;700&family=Merriweather:ital,wght@0,400;0,700;1,400&family=Playfair+Display:ital,wght@0,400;0,600;0,700;1,400&family=Source+Sans+3:wght@400;600;700&family=Work+Sans:wght@400;600;700&display=swap" rel="stylesheet" />
|
||||
```
|
||||
|
||||
Static, in `index.html`, alongside the existing `preconnect` hints — a Google
|
||||
Fonts URL is **never** built from admin input at runtime.
|
||||
|
||||
**Weight coverage gotcha:** IM Fell English ships **400 and italic only — no
|
||||
bold.** `.display` and `.h1` use `font-weight: 600`, and `.btn` / `.eyebrow` /
|
||||
`.badge` use 600–700, so choosing it yields browser-synthesized faux-bold. That is
|
||||
acceptable for the display role (it is the authentic look) but is a reason not to
|
||||
present it as a recommended body face.
|
||||
|
||||
## 6. Storage
|
||||
|
||||
Five new keys. `theme_visual`, `brand_assets` and `nav_public` join `PUBLIC_KEYS`;
|
||||
`nav_admin` and `nav_player` are served by the authenticated endpoint from §4.2.
|
||||
All are JSON strings, absent by default.
|
||||
|
||||
### 6.1 `theme_visual`
|
||||
|
||||
```json
|
||||
{ "preset": "runic-gateway", "custom": null }
|
||||
```
|
||||
|
||||
or, when the admin picks Custom:
|
||||
|
||||
```json
|
||||
{
|
||||
"preset": "custom",
|
||||
"custom": {
|
||||
"colors": { "bg": "#0e1318", "bgDeep": "#0b0f14", "panelA": "#192231", "panelB": "#141a21",
|
||||
"accent": "#7f99bd", "accentBright": "#cdd9e8", "ink": "#eef3f8", "text": "#c4cdd8" },
|
||||
"structure": { "radiusPill": "999px", "radiusPanel": "12px", "radiusCard": "10px",
|
||||
"radiusInput": "8px", "shadowDepth": "0 14px 34px rgba(0,0,0,0.3)" },
|
||||
"fonts": { "serif": "'EB Garamond', Georgia, serif",
|
||||
"display": "Cinzel, Georgia, serif",
|
||||
"sans": "Inter, Arial, sans-serif" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`colors` / `structure` / `fonts` are independently overridable groups — a custom
|
||||
accent without touching radius or fonts is expected. A group or field the admin
|
||||
never touched falls back to whatever preset or `:root` value is active. **Never
|
||||
null a field out to "clear" it** — remove it from the object.
|
||||
|
||||
### 6.2 Preset blocks
|
||||
|
||||
`:root` (no `data-theme` attribute set at all) stays the **Runic Gateway** default
|
||||
— today's actual values — so an instance with no `theme_visual` row renders
|
||||
exactly as it does now. `runic-gateway` is *also* declared as a named preset so
|
||||
that switching back to it after trying another is the same code path.
|
||||
|
||||
```css
|
||||
[data-theme="runic-gateway"] {
|
||||
--bg: #0e1318; --bg-deep: #0b0f14; --panel-a: #192231; --panel-b: #141a21;
|
||||
--accent: #7f99bd; --accent-bright: #cdd9e8; --ink: #eef3f8; --text: #c4cdd8;
|
||||
--radius-pill: 999px; --radius-panel: 12px; --radius-card: 10px; --radius-input: 8px;
|
||||
--serif: Georgia, "Times New Roman", serif;
|
||||
--display: Cinzel, Georgia, serif;
|
||||
--sans: "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* Modern — flatter, cooler, sans-heavy. Reads as a SaaS dashboard, not fantasy. */
|
||||
[data-theme="modern"] {
|
||||
--bg: #101114; --bg-deep: #0a0a0c; --panel-a: #1c1d22; --panel-b: #17181c;
|
||||
--accent: #4f8ef7; --accent-bright: #a8c8ff; --ink: #f2f3f5; --text: #b8bcc4;
|
||||
--radius-pill: 8px; --radius-panel: 8px; --radius-card: 6px; --radius-input: 6px;
|
||||
--serif: Inter, Arial, sans-serif;
|
||||
--display: 'Work Sans', Arial, sans-serif;
|
||||
--sans: Inter, Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* Fantasy — warmer, higher contrast, carved corners; leans into UO harder. */
|
||||
[data-theme="fantasy"] {
|
||||
--bg: #1a120b; --bg-deep: #120c07; --panel-a: #2c1f14; --panel-b: #241a10;
|
||||
--accent: #c9973f; --accent-bright: #e8c374; --ink: #f3e8d4; --text: #d3bfa0;
|
||||
--radius-pill: 4px; --radius-panel: 3px; --radius-card: 2px; --radius-input: 2px;
|
||||
--serif: 'EB Garamond', Georgia, serif;
|
||||
--display: Cinzel, Georgia, serif;
|
||||
--sans: 'EB Garamond', Georgia, serif;
|
||||
}
|
||||
```
|
||||
|
||||
**Derived-token rule (do not break this):** `--panel-grad` and `--shadow-card` must
|
||||
stay expressed *in terms of* the other variables, never written as a literal
|
||||
gradient in a preset block. If `--panel-grad` is ever hardcoded, a future light
|
||||
preset silently inherits a dark gradient and looks broken. Likewise `--mode-live`
|
||||
and `--mode-maint` (the status dots) are **semantic** — green means live — and stay
|
||||
fixed across all presets rather than being themed.
|
||||
|
||||
### 6.3 `brand_assets`
|
||||
|
||||
```json
|
||||
{ "logo": null, "hero": null, "favicon": null }
|
||||
```
|
||||
|
||||
Each field, once set, holds the stored upload URL (`/uploads/1234-abcd.png`) — the
|
||||
same shape `POST /admin/uploads` already returns. A `null` or absent field falls
|
||||
back to `brand.logo` / `brand.hero` / `brand.favicon`; uploading a logo does not
|
||||
force the admin to also pick a hero.
|
||||
|
||||
### 6.4 `nav_public` / `nav_admin` / `nav_player`
|
||||
|
||||
Keyed by the item's existing `to`:
|
||||
|
||||
```json
|
||||
{
|
||||
"/admin/posts": { "label": "Blog Posts", "order": 10 },
|
||||
"/admin/settings": { "hidden": true },
|
||||
"/admin/moderation": { "order": 5, "group": "Content" }
|
||||
}
|
||||
```
|
||||
|
||||
Any field absent for a given `to` falls back to the code default — label from
|
||||
`NAV`, natural array order, `hidden: false`, original group. **Unknown `to` values
|
||||
(not present in the current code's base array) are ignored, not stored and later
|
||||
honored**, so removing a route in code can never leave a dangling override that
|
||||
does something unexpected.
|
||||
|
||||
## 7. Navigation: hard constraint
|
||||
|
||||
The override system can **only** affect `label`, `order`, `hidden`, and — admin
|
||||
nav only — `group` (which *existing* titled section an item sits under).
|
||||
|
||||
It **cannot**:
|
||||
|
||||
- introduce a `to` that is not already in the corresponding hardcoded `NAV` array;
|
||||
- change or remove an item's `roles` (admin nav) or `feature` (public nav) gate;
|
||||
- un-hide an item for a viewer whose role or feature check would otherwise fail.
|
||||
|
||||
The existing filters in
|
||||
[`SiteHeader.jsx:43`](../../website/client/src/components/SiteHeader.jsx) and
|
||||
[`AdminLayout.jsx:155-164`](../../website/client/src/routes/admin/AdminLayout.jsx)
|
||||
run **after** the override merge, unchanged, and remain the actual security
|
||||
boundary. The override layer is presentation-only. This is the same
|
||||
"server-enforced gate, client-side is only about not advertising a dead end"
|
||||
principle already documented in `SiteHeader.jsx`'s comments, and this feature must
|
||||
not weaken it.
|
||||
|
||||
Two existing behaviors the merge must not disturb:
|
||||
|
||||
- **Moderator confinement.** `AdminLayout` restricts moderators to `MOD_PATHS` and
|
||||
redirects them out of anything else. Overrides apply before that filter, so a
|
||||
moderator can still end up with a legitimately short sidebar — but the redirect
|
||||
effect must keep working untouched.
|
||||
- **Empty groups.** `AdminLayout` drops groups whose items all filtered out. An
|
||||
override that hides every item in a group must produce no orphaned header.
|
||||
|
||||
### 7.1 Merge util
|
||||
|
||||
New shared pure module, `client/src/lib/navOverrides.js`:
|
||||
|
||||
```js
|
||||
function applyNavOverrides(baseNav, overrides) {
|
||||
// baseNav: the existing hardcoded array / grouped array — remains the source
|
||||
// of truth for `to`, `roles`, `feature`, `icon`, `end`
|
||||
// overrides: the parsed settings JSON, or null when the admin never touched it
|
||||
// returns: a new array of the same shape with label/order/hidden/group applied
|
||||
}
|
||||
```
|
||||
|
||||
`overrides` absent → return `baseNav` unchanged. This is the "respect defaults"
|
||||
path and is the single most important case to test.
|
||||
|
||||
## 8. Build phases
|
||||
|
||||
Each phase is independently shippable and leaves the site rendering identically to
|
||||
today until the admin acts.
|
||||
|
||||
| Phase | Work |
|
||||
|---|---|
|
||||
| **0 — Settings-store groundwork** | `settingsDb.remove()`; `DELETE /admin/settings/:key` with key allowlist; `GET /settings/nav` (§4.2); `parseJsonSetting()` helper; register the five keys; three into `PUBLIC_KEYS`. Swagger + route-manifest regen |
|
||||
| **1 — `navOverrides.js` + tests** | The pure merge util, unit-tested in isolation. **The one piece with real correctness risk** |
|
||||
| **2 — Radius/shadow token groundwork** | Promote the literals in `theme.css` to the four tokens of §4.7, values unchanged. Verify zero visual diff before any admin UI exists |
|
||||
| **3 — Theme engine** | Three preset blocks, the combined Google Fonts link, `SiteContext` extension, and the effective-value resolution in `getPublic().brand` (§4.5) |
|
||||
| **4 — Admin theme UI** | `/admin/appearance` view + route in `App.jsx` + `NAV`/`TITLES` entries in `AdminLayout.jsx` |
|
||||
| **5 — Brand assets** | Cached-shell rewrite in `app.js` (§4.3); upload endpoint on the existing multer config; `<img>` logo slot beside `MoonDot` in the three shells; `heroImage` chain extension |
|
||||
| **6 — Public nav wiring** | `SiteHeader.jsx` → `nav_public`. Lowest risk of the three: no roles, no groups |
|
||||
| **7 — Nav builder UI** | `NavEditor.jsx` with `@dnd-kit` (new dependency), **Public tab only** |
|
||||
| **8 — Admin + Player nav** | Wire the remaining two layouts, add the remaining two tabs, once the public pattern is validated in use |
|
||||
| **9 — Parchment (optional)** | Light-mode port per §4.8 — its own contrast pass across every component |
|
||||
|
||||
Phases 0–2 are one PR pair (website + docs), 3–4 a second, 5 a third, 6–8 a
|
||||
fourth.
|
||||
|
||||
### 8.1 Admin builder UI notes
|
||||
|
||||
- Tabbed control for the three navs; drag-and-drop reorderable list.
|
||||
- **The palette is filtered to the editing admin's own visible items** — the base
|
||||
array run through *their* role/feature check — so an admin cannot drag in, and
|
||||
therefore can never accidentally expose, an item they cannot already see
|
||||
themselves. A deliberate UX guardrail on top of the merge-time enforcement.
|
||||
- Per item: label input with a "reset to default" that clears the override, an eye
|
||||
toggle for `hidden`, and on the Admin tab a group dropdown limited to the fixed
|
||||
set of titles already in `NAV`.
|
||||
- "Reset to defaults" per nav **deletes the row** (§4.1), never saves `{}`.
|
||||
|
||||
## 9. Acceptance criteria
|
||||
|
||||
- Fresh instance, no admin action: colors, fonts, radii, brand assets and all
|
||||
three navs render byte-for-byte as today, driven by `BRAND_*` and the current
|
||||
hardcoded `theme.css` / `NAV` arrays.
|
||||
- After Phase 2 and before any admin UI exists, the rendered site is visually
|
||||
identical — the token promotion is a true no-op.
|
||||
- Setting `theme_visual.custom.colors` alone changes colors only; radius, fonts,
|
||||
assets and nav are unaffected.
|
||||
- Font dropdowns only ever produce values from the §5.1 shortlist. No admin input
|
||||
is concatenated into a `font-family` string or a Google Fonts URL at runtime.
|
||||
- Setting only `brand_assets.favicon` changes the served favicon only — the OG
|
||||
image and hero backgrounds still resolve from `brand.js` env values.
|
||||
- With no `brand_assets` row, the served HTML shell is **byte-identical** to
|
||||
today's. Covered by a server-side test in `publicBrand.test.js`.
|
||||
- Uploaded assets go through the existing `imageUpload.js` mimetype allowlist. No
|
||||
second upload path with weaker validation.
|
||||
- `getPublic().brand` with no new rows returns exactly what it returns today —
|
||||
the existing `publicBrand.test.js` assertions pass verbatim.
|
||||
- An admin cannot, through the nav builder, cause any user to see a nav item their
|
||||
role/feature gate would otherwise hide. Verified by overriding `hidden: false`
|
||||
on a role-gated item as a lower-privileged test admin and confirming the filter
|
||||
still hides it.
|
||||
- Deleting a theme/asset/nav row returns that surface to env/code defaults, not to
|
||||
a stored copy of the defaults.
|
||||
Reference in New Issue
Block a user