Compare commits

1 Commits

Author SHA1 Message Date
152ffef86e docs(installer): add release orchestration and the bundle manifest
The installer needs CI that reacts when a component publishes a release. Adds
that as section 7, folded into version tracking because the bundle IS the compat
matrix -- which closes the "where does the compat matrix live" gap section 7
previously left open.

- 7.1 Bundle manifest: CI publishes an exact, protocol-checked combination of
  component versions; the installer resolves against it at run time and
  --bundle <tag> pins one. A link release regenerates JSON and leaves the
  installer binary untouched, so operators don't re-download the installer for a
  sidecar patch and the repo doesn't accumulate releases with identical code.
  Two compose-time gates: sidecar PROTOCOL_VERSION must equal the overlay
  manifest's declared version, and every asset's SHA256 must match.
- 7.2 Triggers: each component's release job POSTs to the installer's
  workflow-dispatch endpoint (link's release.yml already declares
  workflow_dispatch and already holds a write:repository token), plus a nightly
  cron so a missed dispatch self-heals. repository_dispatch avoided -- support
  is uncertain on this Gitea version.
- 7.3 Stale overlay: dispatch, don't wait. Components self-release on merge to
  their own main, so the release normally already exists. If main is ahead with
  *releasable* commits (docs:/chore: correctly cut nothing), fire that repo's
  workflow, compose from what exists now, warn loudly, and let the nightly fold
  in the result. Dispatching another repo's workflow is fine -- it still runs
  its own gates -- but polling it is not, since Gitea's dispatch endpoint
  returns no run handle.

Bundle CI becomes a Phase 0 deliverable, since Phase 1 resolves what to install
from the bundle. `update` now moves between checked combinations rather than two
independently-latest artifacts.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 06:09:58 -05:00
17 changed files with 71 additions and 5627 deletions

View File

@@ -7,25 +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 |
| [MODULE_SYSTEM.md](website/MODULE_SYSTEM.md) | Making the site game-agnostic: game logic becomes an installable module — design of record |
| [MODULE_API.md](website/MODULE_API.md) | The module ↔ core contract: `ctx`, the `register*` calls, the client registry and the loader's obligations |
| [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 |
@@ -58,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`.

View File

@@ -202,28 +202,3 @@ class LoginViewModelTest {
- `MainDispatcherRule` + a documented ViewModel test pattern exist and are reused.
- Coverage exclusions list only genuinely non-unit-testable files (UI composables, Android-framework
glue) — no ViewModel, repository, DTO, or pure core-logic file is excluded.
## 6. Amendment (2026-08-08, M12 phase 8): `ui/theme/**` narrowed to one file
**A directory glob in the exclusion list went stale as soon as another milestone put testable code
in that directory.** §2 phase 0 excluded `app/src/main/java/**/ui/theme/**` because at the time the
directory held `Color.kt`, `Type.kt` and the composables — constants and composable bodies, nothing
a JVM test could execute. M12 then added three *pure* resolvers to it: `ShardPalette`,
`ShardStructure` and `ShardTypeface`, which exist precisely so the theming milestone's no-op
invariants could be plain JVM assertions. JaCoCo measures them at **98%, 100% and 100%**, and the
glob was discarding every line.
The exclusion is now the single file it was really about, `ui/theme/Theme.kt` (the composable, 52%).
Everything else in `ui/theme/` is measured and all of it covers at 93% or better.
Two things worth carrying forward:
- **This did not rescue the gate and was not meant to.** M12's already-measured code
(`data/appearance/` at 100%, `ui/navigation/` at 93–100%) clears `new_coverage ≥ 50` on its own.
The point is that a future change deleting those resolvers' tests would now move the number, where
before it would not have — the exclusion was hiding well-tested code, which is the opposite of what
§1 built the list for and what §5's third bullet asks for.
- **Prefer file globs to directory globs when a directory is mixed.** `ui/components/**` stays a
directory glob and correctly so: `BrandAssets.kt` sits at 11% because only `brandAssetUrl` is pure,
and the rest is composable bodies. The distinction is whether the directory is *uniformly*
untestable, not whether it is under `ui/`.

View File

@@ -990,101 +990,6 @@ push, and Play (M6–M8) follow the designed app.
Visibility, Spawn Atlas and Cliloc import — alongside the hero/CMS block editor, Discord-bot
config, uo-link config and OAuth-provider setup.
13. **M12 — Admin theming & navigation parity** (post-v1; scoped 2026-08-08). The website merged
runtime admin theming, brand assets and nav overrides to `main` (website#126 / docs#109). The app
reads exactly one field of it — `brand.accent` — and renders a hardcoded `APP_MENU`, so an admin
who re-skins the site and restructures the header sees none of it on the phone. This milestone
makes the app a full consumer of that contract.
**Design of record: [`THEMING_AND_NAV.md`](./THEMING_AND_NAV.md)** — the token map, the phase
list and the locked decisions live there rather than here, mirroring how the website side kept
[`../website/THEMING_AND_NAV.md`](../website/THEMING_AND_NAV.md) separate from its own plan.
**No backend work.** Everything consumed is already live on `website/main`:
`GET /public/settings` gained `theme` (the full resolved token map) and `nav_public`, its `brand`
block now returns *effective* values, and `GET /api/v1/settings/nav` serves the admin/player
overrides to any authenticated account.
The points that shaped the plan, and that a reader of this file should know without opening it:
- **The app's palette is already the `runic-gateway` preset**, value for value — M5 was drawn
from the same `theme.css` the preset was later extracted from. So the website's governing
invariant (an untouched instance renders byte-for-byte as before) carries over as a *testable
equality assertion* on the resolved `ColorScheme`, not an approximation.
- **Radii apply as a ratio, not as literal dp.** The app's `Shapes` came from the M5 mockup and
genuinely differ from the web tokens (`medium` 12dp vs `--radius-card` 10px); a literal mapping
would restyle the untouched app the day this ships. A ratio against the `runic-gateway`
baseline makes an untouched instance a provable no-op while still tracking the admin's intent.
- **Fonts are bundled, not downloadable.** Seven families join the already-bundled Cinzel
(~1.5–2.5 MB, against a 4.2 MB signed release). Downloadable fonts were rejected: they need the
Play Store provider, so a de-Googled device silently falls back.
- **Nav overrides are keyed by *website* paths**, so the app needs a path → route table — the one
new cross-repo coupling here. Two asymmetries are decided rather than papered over: an override
for a path the app does not surface in its menu (champs / guilds / governors / houses, which
live behind the Shard hub) is **ignored**, because a nav override may never *introduce*
navigation; and the app's own entries with no web counterpart keep their coded order.
- **The gates are untouched.** `MenuAccess` and `MenuEntry.feature` still run *after* the merge,
so `hidden: false` cannot un-hide what a role or the shard's visibility config withholds — the
same boundary the website's §7 draws.
- **The authenticated navs are thinner than they look.** `nav_player` reaches two app rows and
`nav_admin` two (`/player`, `/account`, `/admin`, `/admin/moderation`); the sidebar's other
~18 rows are admin *configuration* the app excludes, and two of the app's four staff entries
are aggregates with no single web row. That is why they honor `label` and `hidden` only — and
why that phase was scheduled **last and marked optional**, so it could be dropped on its
merits. **It was: phase 7 is cancelled (2026-08-08).** The app makes no authenticated settings
call, `nav_admin` / `nav_player` are not consumed, and the player and staff drawer rows keep
their coded — and therefore localized — labels. Nothing in phases 0–6 was scaffolding for it.
- **Excluded**, in the same class as M10's and M11's exclusions: the admin *configuration* panels
themselves. The app does not gain Appearance or Navigation editors; it is a consumer.
Eight phases (0–6 and 8; **7 cancelled**) into a fresh `edge` in both repos, reaching `main` as
one `edge` → `main` merge — the same shape the website side used. Phase 0 (the contract and the
appearance store) carries a hard rule: it must change nothing on screen. **Phase 0 landed
2026-08-08** (Android-app#33 / docs#112): `SiteAppearance` now holds the brand, the resolved
theme tokens and the parsed `nav_public`, refreshed on resume, with nothing yet reading the last
two — see THEMING_AND_NAV.md "Phase 0 as landed". **Phase 1 landed 2026-08-08**
(Android-app#34 / docs#113): the fifteen color tokens now resolve into a `ShardPalette` that
feeds both the Material scheme and a `LocalShardPalette`, and the no-op invariant is a test —
the shipped palette reproduces the pre-M12 `ColorScheme` role for role. **Phase 2 landed
2026-08-08** (Android-app#35 / docs#114): the four radii scale the app's own dp by their ratio
to the `runic-gateway` baseline, and `--shadow-card` maps onto card elevation. The shadow is
a **deliberate change to an untouched instance** — the app has been flat since M5 while the
preset it was drawn from selects a shadow — and is why every `Card(` became a `ShardCard(`:
Material takes elevation as a default argument, not from the theme. **Phase 3 landed
2026-08-08** (Android-app#36 / docs#115): seven bundled families beside Cinzel, resolved from
the stacks the theme publishes. It **nearly tripled the APK** — 4.80 → 12.94 MiB against a
drafted 1.5–2.5 MB estimate, with Merriweather alone 6.08 MiB of the growth because upstream
ships it as a barely-compressible three-axis variable font; verbatim bundling was chosen with
the cheaper options costed and declined. **Phase 4 landed 2026-08-08** (Android-app#37 /
docs#116): logo in the drawer header and top bar, hero on Home, no new dependency and no APK
cost. **Phase 5 landed 2026-08-08** (Android-app#38 / docs#117): the public nav's label, order
and hiding, keyed by the path→route table — whose sort keys must be indices into the
*website's* sixteen-row nav, not the app's nine. **Phase 6 landed 2026-08-08**
(Android-app#39 / docs#118): the drawer gains admin-authored sections and added links, a link
the app can open natively doing so and one it cannot handing off to a Custom Tab.
**Phase 8 landed 2026-08-08** (Android-app#40 / docs#119) and did more than its name. Scoped
as docs, coverage and the cutover, its **AC-5 on-device walk** — two AVD passes against a local
website, the themed one at every role rung — confirmed everything phases 1–6 had deferred to it
(including that a Custom Tab really opens, checked in `dumpsys`, and that an anonymous caller
sees the reordered, sectioned nav with no player or staff row leaking) **and found two theming
defects that 476 passing tests could not see**:
- every `ShardCard` — 26 sites in 20 files — drew in Material's grey rather than the shard's
panel colour, because `CardDefaults.cardColors()` takes its container from
`surfaceContainerHighest` and the mapping had `surfaceContainer`, `…High` and `…Low` but not
`…Highest`; and
- the drawer's selected row ignored `--radius-pill`, because `NavigationDrawerItem` takes
`shape` as a default argument.
Both are **phase 2's trap repeating**: Material takes these as default arguments, not from the
theme. Phase 2 found it for depth and swept the call sites; nobody asked whether colour and
shape had the same problem. The card fix also **changes the untouched app** — those grey cards
predate M12 and go back to M5 — so the milestone ships two deliberate changes to a shard that
has set nothing, the shadow and the card container, with AC-1 rewritten to record the second
rather than absorb it. Phase 8 also narrowed a stale `ui/theme/**` Sonar coverage glob that was
discarding three 98–100%-covered resolvers. **477 tests green.**
### Deferred (not a milestone)
- **`/api/mobile` facade migration + app-version floor** — briefly planned as its own milestone

View File

@@ -20,14 +20,7 @@ android-app/
│ └── sync-project-tree.yml
├── app/
│ ├── licenses/
│ │ ├── Cinzel-OFL.txt
│ │ ├── EBGaramond-OFL.txt
│ │ ├── IMFellEnglish-OFL.txt
│ │ ├── Inter-OFL.txt
│ │ ├── Merriweather-OFL.txt
│ │ ├── PlayfairDisplay-OFL.txt
│ │ ├── SourceSans3-OFL.txt
│ │ └── WorkSans-OFL.txt
│ │ └── Cinzel-OFL.txt
│ ├── src/
│ │ ├── debug/
│ │ │ └── res/
@@ -104,9 +97,6 @@ android-app/
│ │ │ │ │ │ ├── PlayerShardApi.kt
│ │ │ │ │ │ ├── PublicApi.kt
│ │ │ │ │ │ └── SsoApi.kt
│ │ │ │ │ ├── appearance/
│ │ │ │ │ │ ├── SettingsJson.kt
│ │ │ │ │ │ └── SiteAppearance.kt
│ │ │ │ │ └── repository/
│ │ │ │ │ ├── AccountRepository.kt
│ │ │ │ │ ├── AdminRepository.kt
@@ -144,7 +134,6 @@ android-app/
│ │ │ │ │ │ ├── TrustedDevicesScreen.kt
│ │ │ │ │ │ └── TrustedDevicesViewModel.kt
│ │ │ │ │ ├── components/
│ │ │ │ │ │ ├── BrandAssets.kt
│ │ │ │ │ │ ├── HtmlText.kt
│ │ │ │ │ │ ├── StateViews.kt
│ │ │ │ │ │ └── ThemeComponents.kt
@@ -159,9 +148,6 @@ android-app/
│ │ │ │ │ │ └── HomeViewModel.kt
│ │ │ │ │ ├── navigation/
│ │ │ │ │ │ ├── Menu.kt
│ │ │ │ │ │ ├── NavOverrides.kt
│ │ │ │ │ │ ├── NavPaths.kt
│ │ │ │ │ │ ├── NavTree.kt
│ │ │ │ │ │ └── Routes.kt
│ │ │ │ │ ├── news/
│ │ │ │ │ │ ├── NewsScreen.kt
@@ -213,9 +199,6 @@ android-app/
│ │ │ │ │ │ ├── BrandColor.kt
│ │ │ │ │ │ ├── Color.kt
│ │ │ │ │ │ ├── Font.kt
│ │ │ │ │ │ ├── ShardPalette.kt
│ │ │ │ │ │ ├── ShardStructure.kt
│ │ │ │ │ │ ├── ShardTypeface.kt
│ │ │ │ │ │ ├── Theme.kt
│ │ │ │ │ │ └── Type.kt
│ │ │ │ │ ├── wiki/
@@ -243,18 +226,7 @@ android-app/
│ │ │ │ ├── drawable-xxhdpi/
│ │ │ │ │ └── ic_stat_name.png
│ │ │ │ ├── font/
│ │ │ │ │ ├── cinzel_variable.ttf
│ │ │ │ │ ├── eb_garamond_italic.ttf
│ │ │ │ │ ├── eb_garamond_variable.ttf
│ │ │ │ │ ├── im_fell_english_italic.ttf
│ │ │ │ │ ├── im_fell_english_regular.ttf
│ │ │ │ │ ├── inter_variable.ttf
│ │ │ │ │ ├── merriweather_italic.ttf
│ │ │ │ │ ├── merriweather_variable.ttf
│ │ │ │ │ ├── playfair_display_italic.ttf
│ │ │ │ │ ├── playfair_display_variable.ttf
│ │ │ │ │ ├── source_sans_3_variable.ttf
│ │ │ │ │ └── work_sans_variable.ttf
│ │ │ │ │ └── cinzel_variable.ttf
│ │ │ │ ├── mipmap-anydpi-v26/
│ │ │ │ │ ├── ic_launcher.xml
│ │ │ │ │ └── ic_launcher_round.xml
@@ -331,9 +303,6 @@ android-app/
│ │ │ │ ├── FakePlayerShardApi.kt
│ │ │ │ ├── FakePublicApi.kt
│ │ │ │ └── FakeShardStream.kt
│ │ │ ├── appearance/
│ │ │ │ ├── SettingsJsonTest.kt
│ │ │ │ └── SiteAppearanceTest.kt
│ │ │ └── repository/
│ │ │ ├── AccountTrustedDevicesTest.kt
│ │ │ ├── ConnectionVersionGuardTest.kt
@@ -344,16 +313,11 @@ android-app/
│ │ │ │ ├── AdminDashboardViewModelTest.kt
│ │ │ │ ├── AdminModerationViewModelTest.kt
│ │ │ │ └── AdminSupportViewModelTest.kt
│ │ │ ├── components/
│ │ │ │ └── BrandAssetsTest.kt
│ │ │ ├── contact/
│ │ │ │ └── ContactViewModelTest.kt
│ │ │ ├── navigation/
│ │ │ │ ├── MenuAccessTest.kt
│ │ │ │ ├── MenuFeatureGatingTest.kt
│ │ │ │ ├── NavOverridesTest.kt
│ │ │ │ ├── NavPathsTest.kt
│ │ │ │ └── NavTreeTest.kt
│ │ │ │ └── MenuFeatureGatingTest.kt
│ │ │ ├── notifications/
│ │ │ │ └── NotificationRoutingTest.kt
│ │ │ ├── player/
@@ -368,11 +332,7 @@ android-app/
│ │ │ │ ├── ShardContentViewModelTest.kt
│ │ │ │ └── ShardEventTextTest.kt
│ │ │ ├── theme/
│ │ │ │ ├── BrandColorTest.kt
│ │ │ │ ├── ShardColorSchemeTest.kt
│ │ │ │ ├── ShardPaletteTest.kt
│ │ │ │ ├── ShardStructureTest.kt
│ │ │ │ └── ShardTypefaceTest.kt
│ │ │ │ └── BrandColorTest.kt
│ │ │ ├── ContentViewModelTest.kt
│ │ │ └── UiStateTest.kt
│ │ ├── util/

File diff suppressed because it is too large Load Diff

View File

@@ -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 |

File diff suppressed because it is too large Load Diff

View File

@@ -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
```

View File

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

View File

@@ -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

View File

@@ -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.
```

View File

@@ -119,20 +119,6 @@ server/
vendors, chars, sales, houses
appeals.router.js (4) /player/appeals
shard.controller.js + appeals.controller.js
settings/ index.js owns the shared `noindex, requireAuth` gate
(authenticated, ANY role) and the mount table.
A fifth group, for site-wide settings that
need a login but no particular role — /public
is anonymous, /admin/settings is adminOnly
while AdminLayout renders for editors and
moderators, and /player is self-scoped data
nav.router.js (1) /settings/nav — the nav_admin and
nav_player overrides, read by the
layouts that render them
theme.router.js (1) /settings/theme/options — the closed
sets the admin appearance form is
built from. Static; no DB read
nav.controller.js + theme.controller.js
admin/ index.js mounts the capability routers below at their
own prefixes; owns the shared
`noindex, isLoggedIn, staffOnly` gate and
@@ -165,15 +151,7 @@ server/
email.router.js (6) /admin/email — Gmail OAuth2
delivery — adminOnly
discordBot.router.js (2) /admin/discord-bot — adminOnly
settings.router.js (4) /admin/settings — adminOnly. The
DELETE /:key is "reset to default"
and carries its own key allowlist
(theming/nav keys + the hero draft)
so it can never drop site_mode or
the uo-link config; POST
/brand-asset/:slot uploads a
logo/hero/favicon and writes the
brand_assets row in the same call
settings.router.js (2) /admin/settings — adminOnly
dashboard.router.js (2) GET /dashboard (staff-wide) and
PUT /site-mode (adminOnly) — the
two singletons owning no path
@@ -270,61 +248,6 @@ Seeded keys: `site_mode` (default `maintenance`), `site_mode_changed_at`,
`site_mode_changed_by`, `maintenance_message`, `status_message`, `homepage_teaser`,
`contact_email` (=UOMysticmoon@gmail.com), `site_title`.
**Deliberately unseeded keys** — the theming & navigation overrides
(`theme_visual`, `brand_assets`, `nav_public`, `nav_admin`, `nav_player`). All
five are JSON strings, and **the absence of the row is the "use the default"
state**: colors/fonts/radii fall back to `theme.css`, assets to `BRAND_*`, navs
to the hardcoded `NAV` arrays. No migration writes defaults into them, because a
stored copy of a default would stop tracking the default. Resetting one is
therefore a `DELETE`, not a write — see `DELETABLE_KEYS` in `settings.model.js`
and [THEMING_AND_NAV.md](THEMING_AND_NAV.md) §2.
Values are `TEXT`, so a JSON-valued key arrives as a **string** and every
consumer parses it. Server side that is `utils/settingsJson.js`
(`parseJsonSetting`), client side `client/src/lib/settingsJson.js` and
`parseLayout`; both treat a malformed or wrong-shaped value as **absent** rather
than as an error, so a hand-edited row degrades to the default instead of
rendering something broken.
**The three `nav_*` rows are presentation, never authorization.** An entry is
keyed by an item's existing `to` and may carry only `label`, `order`, `hidden`
and — admin nav only — `group`; `utils/navOverrides.js` rejects anything else on
write, naming the key. It deliberately does **not** check that a `to` exists: the
base `NAV` arrays are client constants, and duplicating them server-side would
create a second source of truth for navigation that drifts the first time a route
is added. `client/src/lib/navOverrides.js` drops an unknown `to` at merge time
instead, which is also what makes deleting a route in code safe. The merge runs
*before* the role and shard-feature filters in `SiteHeader.jsx` /
`AdminLayout.jsx`, which are unchanged and remain the boundary — a stored
`hidden: false` on a gated item shows nobody anything. `hidden: false` is
accepted (the editor sends it mid-edit) but never stored, so hiding stays
subtractive. `hidden` on `/admin/navigation` is dropped for `nav_admin`, because
that screen is the only UI that can un-hide anything.
**`nav_public` may also carry dropdown sections and admin-authored links**, as
`{ items, sections, links }` — a bare map still reads as `items`, and a nav with
no sections still stores one. A **section** has a label and a position and no
route at all: it only opens, so it adds no reachable surface. A **link** is the
one place a path may be named that the code does not declare, and is therefore
the one place the path rule applies: same-origin only, no scheme and no
protocol-relative `//host`. A link carries no gate of its own and needs none —
the page behind it enforces its own access, so an added link advertises a route
and never grants one. Coded entries stay in `items`, keyed by a route the base
array must declare, which is what keeps "an override cannot introduce a route"
structurally true. Sections and links are dropped for `nav_admin` / `nav_player`,
whose layouts cannot render them.
**`theme_visual` is resolved server-side, not shipped raw to the browser.**
`utils/themeResolve.js` layers `:root` ← preset ← custom, field by field, into
the CSS custom properties `getPublic()` returns as `theme`; the SPA's only job
is to write them onto `<html>` and take back what it wrote last time
(`client/src/lib/themeVars.js`). One authority for the merge means the effective
accent in `brand.accent` — the cross-repo contract the Android app and the
Discord bot theme themselves from — always agrees with what the website paints.
Values reaching a CSS variable are checked against closed sets on both paths:
strictly on write (400, naming the field) and forgivingly on read (drop the bad
field, keep its neighbours).
### activity_log — append-only
| col | type | notes |
|---|---|---|
@@ -679,7 +602,7 @@ are authoritative, and they answer different questions:
| Artifact | Source of truth for | Generated by |
|---|---|---|
| `server/routes.manifest.json` — mirrored as [api-route-inventory.json](./api-route-inventory.json) | **What URLs exist.** 226 public routes + 2 on the internal listener, sorted, method + path only. | `npm run routes:manifest`, by walking the live Express stack |
| `server/routes.manifest.json` — mirrored as [api-route-inventory.json](./api-route-inventory.json) | **What URLs exist.** 215 public routes + 2 on the internal listener, sorted, method + path only. | `npm run routes:manifest`, by walking the live Express stack |
| `server/swagger/swagger-output.json` — served at `/api/docs` | **What each route means.** Parameters, bodies, response codes, security. | `npm run swagger`, from `#swagger.*` annotations |
The split is deliberate: Swagger is annotation-derived, so an unannotated route is invisible in it and
@@ -855,7 +778,7 @@ from the per-route **siteMode** middleware (§5), never from an auth gate.
| Method | Path | Notes |
|---|---|---|
| GET | `/settings` | whitelisted public keys, derived `registration`/`gameAccountSignup` flags, the per-shard **`brand`** block (name, `accent` color, logo/hero/favicon) a client themes itself from — one image runs as any shard, asset fields may be site-relative paths (resolve against the base URL); these are **effective** values, so an admin theme (`theme_visual`) beats `BRAND_ACCENT_COLOR` and an uploaded `brand_assets` asset beats its `BRAND_*` path — an optional **`theme`** block, the resolved CSS custom properties for that admin theme (absent when the instance was never themed, which is what makes it render from the shipped stylesheet unchanged) — and a **`push`** block `{ ntfyUrl }` (M7): the client-facing ntfy relay URL the app's embedded distributor registers its device topic against, from `NTFY_PUBLIC_URL` / first `NTFY_ALLOWED_ORIGINS` (never the internal `NTFY_BASE_URL`); `null` when push isn't configured for the shard. |
| GET | `/settings` | whitelisted public keys, derived `registration`/`gameAccountSignup` flags, the per-shard **`brand`** block (name, `accent` color, logo/hero/favicon) a client themes itself from — one image runs as any shard, asset fields may be site-relative paths (resolve against the base URL) — and a **`push`** block `{ ntfyUrl }` (M7): the client-facing ntfy relay URL the app's embedded distributor registers its device topic against, from `NTFY_PUBLIC_URL` / first `NTFY_ALLOWED_ORIGINS` (never the internal `NTFY_BASE_URL`); `null` when push isn't configured for the shard. |
| GET | `/status` | status message + current mode, **plus a `version` block** (`{ service:'runic-gateway', api, server }`) so a client first-run probe recognizes the backend and can run a version-mismatch guard |
| GET | `/version` | lightweight, **DB-free** backend identity/version (`{ service, api, server }`) — the canonical target for the version guard and a cheap liveness check |
| GET | `/posts/:category` | published only; `category` ∈ news\|five-on-friday\|newsletter\|screenshots |
@@ -879,18 +802,6 @@ from the per-route **siteMode** middleware (§5), never from an auth gate.
Public content GETs pass through the **siteMode** gate (§5).
### /settings (settings/index.js → §2) — behind `requireAuth` + `noindex`, no role gate
Site-wide settings that need a login but no particular role. It exists because the
other four groups each answer a different question: `/public` is anonymous,
`/admin/settings` is `adminOnly`, and `/player` is data scoped to `req.user.id`.
These rows are configuration that happens to need a login.
| Method | Path | Purpose |
|---|---|---|
| GET | `/settings/nav` | `{ nav_admin, nav_player }` — the stored nav overrides as raw JSON strings (or `null`), for the two authenticated layouts that render them. Deliberately not public: an anonymous visitor has no use for either, and the admin nav's labels describe the shape of the admin surface. Open to **any** role because `AdminLayout` renders for editors and moderators and `PlayerPortalLayout` for players, none of whom can read `GET /admin/settings`. Presentation-only — the role/feature filters in those layouts still decide what is shown, and an override can never un-hide a gated item (see [THEMING_AND_NAV.md](THEMING_AND_NAV.md) §7) |
| GET | `/settings/theme/options` | The closed sets an admin may pick from when theming the site: the presets (each with its **full token map**, so a form can show what an unset field currently resolves to), the curated Google Fonts shortlist per role, the shadow depths, the editable color/radius field names paired with the CSS variable each drives, and `shippedTokens` (what `theme.css`'s `:root` declares). Static — derived from `config/themePresets.js`, no DB read. Served rather than duplicated in client code so the options the form **offers** can never drift from the ones `PUT /admin/settings` **accepts** |
### /admin (admin/index.js → the capability routers in §2) — all behind `isLoggedIn` + `noindex` + `staffOnly`
`admin/index.js` applies the shared gate and mounts each capability router at the prefix it owns;
@@ -925,9 +836,7 @@ file a route sits in — that is the property the route manifest freezes.
| POST | `/posts/upload` | multipart image upload (multer) → `{image_url}` for screenshots |
| GET | `/wiki` · GET `/wiki/:slug` | read incl. unpublished |
| POST | `/wiki` · PUT `/wiki/:slug` · DELETE `/wiki/:slug` | manage pages |
| GET | `/settings` · PUT `/settings` | read all / update `{key:value,...}`. Enum-constrained keys are validated on the way in; `theme_visual` additionally has every value checked against the closed sets in `config/themePresets.js` (hex color, shortlisted font stack, bounded px radius, listed shadow) and is stored stringified, and `brand_assets` has every slot checked against `utils/brandAssets.js` — a same-origin path under `/uploads/`, `/brand/` or `/assets/`, never an off-origin or protocol-relative URL, since these values are written straight into the page as an `<img src>` / `<link rel=icon>` / `og:image`. Cleared slots are dropped rather than stored as `null`. The three `nav_*` keys go through `utils/navOverrides.js` on the same path — shape only (`label`/`order`/`hidden`/`group` keyed by an app path), since whether a key names a route the nav declares is settled client-side at merge time; without this they would reach the store as `"[object Object]"` and read as absent for ever. A write to `brand_assets` or `theme_visual` invalidates the cached HTML shell (a nav write does not — nav is not in the shell). The read path drops bad fields anyway, so the `400` is about **feedback** — a save that appears to succeed and then does nothing is worse than a rejection |
| DELETE | `/settings/:key` | reset one setting to its default by deleting the row. Allowlisted to the keys whose default lives outside the store (`theme_visual`, `brand_assets`, `nav_public`, `nav_admin`, `nav_player`, `hero_layout_draft`) — anything else is `400`. Idempotent: resetting a key that was never set succeeds |
| POST | `/settings/brand-asset/:slot` | upload one brand asset (`logo` · `hero` · `favicon`) **and** point `brand_assets` at it, in one call → `{ url, brand_assets }`. One call rather than "upload, then PUT" so a half-completed save never leaves an unreferenced file in `/uploads`. Uses the shared `imageUpload.js` multer config — the mimetype allowlist is never widened, only tightened per slot: favicons are **PNG only** (§4.10 of [THEMING_AND_NAV.md](THEMING_AND_NAV.md)) and capped at 512 KB, logos at 1 MB, heroes at the shared 8 MB. A refused file is unlinked before the response. Merges into the existing overrides, so uploading a logo never clears a hero. `adminOnly` — tighter than the generic `POST /admin/uploads`, which editors may reach |
| GET | `/settings` · PUT `/settings` | read all / update `{key:value,...}` |
| GET | `/activity?limit=&offset=` | paginated activity log |
| GET | `/users` · POST `/users` · PUT `/users/:id` · DELETE `/users/:id` | user mgmt (can't delete self / last admin; password hashed on write) |
| GET | `/users/:id/trusted-devices` | list a user's active trusted devices (never tokens) |
@@ -943,42 +852,6 @@ file a route sits in — that is the property the route manifest freezes.
Every admin write logs to `activity_log`.
### The SPA HTML shell (`app.js` → `utils/htmlShell.js`)
The SPA catch-all serves `client/dist/index.html` with this instance's branding templated into the
`<head>` — title, meta description, Open Graph / Twitter tags, `<link rel="icon">` — so one prebuilt
image serves per-instance metadata to a crawler that never runs the JavaScript.
That used to be a single render at module load, from `BRAND_*` env only. It cannot be, now that the
favicon and OG image can come from the admin's `brand_assets` row: the shell depends on state that
changes while the process runs. `utils/htmlShell.js` owns the lifecycle, and three properties are
deliberate:
- **A cached string in the steady state.** The shell is rendered lazily on first request and reused;
a settings read per page view would put the database on the critical path of every SPA route,
including during an outage where the API is already degraded. Concurrent first requests share one
render.
- **A DB fault never fails the page.** A failed read renders the env-only shell — exactly the
pre-feature behavior — and that result is cached like any other, so an outage does not become a
failing query per page view.
- **Byte-identical with no rows.** An instance that has never been themed and has uploaded nothing
gets the same bytes it got before the feature existed. Locked by `test/htmlShell.test.js`, which
keeps a verbatim copy of the old renderer as its reference.
Invalidation is explicit — the settings controller calls `htmlShell.invalidate()` after a successful
write to `brand_assets` or `theme_visual` — with a **5-minute TTL as a safety net**, because the cache
is per process: in a scaled deployment the worker that handled the write is the only one that learns
of it, and without the TTL every other worker would serve the old favicon until the next restart.
The shell also carries the resolved theme as a `<style id="theme-boot">:root{…}</style>` block, last
in `<head>` so it follows the built stylesheet and wins the equal-specificity tie. It exists only to
stop a themed instance painting the shipped palette for one frame; `SiteContext` removes it once the
`/public/settings` payload has arrived and applied — gated on a **successful** fetch, since dropping
it after a failed one would strip a themed instance back to the shipped colors. Token names and
values are re-checked against conservative patterns on the way into the block: everything there comes
from a closed set already, and this keeps that a property of the HTML writer rather than of a
validator three modules away.
---
## 5. Site mode (LIVE / MAINTENANCE)

View File

@@ -1,590 +0,0 @@
# The Module API — the contract
**Status:** Phase 1 deliverable of [MODULE_SYSTEM.md](MODULE_SYSTEM.md). This document is the
normative contract between the core website and an installed module. `MODULE_SYSTEM.md` decides
*what* the module system is; this decides *exactly what a module may call, what it must provide, and
what core promises not to break*.
Everything below is derived from what the UO code actually does today, re-read against the working
tree on 2026-08-10. Where the survey contradicted `MODULE_SYSTEM.md`, the contradiction is recorded
in Part 6 rather than quietly resolved — four of them, one of which (OpenAPI, §6.1) needs a decision
before Phase 2 starts.
**The one rule everything else serves:** a module reaches core *only* through the members named in
this document. Zero `require`/`import` from a module to a core file, enforced in CI (§5.1). A core
refactor that leaves this contract intact cannot break a module; anything a module needs that is not
here extends the contract first, in this file, before the module is written against it.
---
## Part 1 — Versioning
### 1.1 `MODULE_API_VERSION`
Core exports a single integer-major semver string from `server/src/modules/version.js`:
```js
const MODULE_API_VERSION = '1.0.0'
```
Every `module.json` declares a `coreApi` semver **range**. The loader checks it at boot, before it
requires a line of module code, and a mismatch fails that module loudly into `startup_failed`
(§4.4) with the two versions in the reason. It never silently proceeds.
| Change | Bump |
| --- | --- |
| A member is added to `ctx`, or a new `register*` call appears | minor |
| A member is removed or its signature changes | major |
| Behaviour of an existing member changes without a signature change | major |
| A core-internal refactor behind an unchanged member | none |
This is a **separate number from `PROTOCOL_VERSION`**, which versions the shard wire and has nothing
to say about a website module. It is also separate from the module's own version.
### 1.2 What is *not* contract
Core's internal file layout, table names, middleware ordering, the `api` client object's shape, and
every component under `client/src/components/` except the ones named in §3.4. A module that reaches
any of these is out of contract even if it happens to work.
---
## Part 2 — The server contract
### 2.1 `module.json`
Read synchronously by the loader from `modules/<id>/module.json`. Unknown top-level keys are
rejected rather than ignored, so a typo is a loud failure and not a silently-inert setting.
```json
{
"id": "uo",
"name": "Ultima Online",
"version": "1.0.0",
"coreApi": "^1.0.0",
"server": "server/index.js",
"client": { "entry": "client/dist/entry.js" },
"schema": "server/db/schema.sql",
"purge": "server/db/purge.sql",
"mounts": {
"public": ["/shard", "/atlas"],
"admin": ["/shard", "/uo-link"],
"player": ["/shard"]
},
"extensions": ["admin.users.detail"],
"capabilities": ["shard", "atlas", "market"]
}
```
| Key | Required | Meaning |
| --- | --- | --- |
| `id` | yes | `^[a-z][a-z0-9-]{1,31}$`. The directory name, the `installed_modules` key, the URL segment, the `window.__rg` registry key. Must equal the directory it was read from. |
| `name` | yes | Human label for the admin Modules screen. |
| `version` | yes | Semver. Recorded in `installed_modules`; shown on failure. |
| `coreApi` | yes | Semver range checked against `MODULE_API_VERSION` (§1.1). |
| `server` | no | Entry point, relative to the module root. Absent ⇒ client-only module. |
| `client.entry` | no | Prebuilt ESM chunk, relative to the module root. Absent ⇒ server-only module. |
| `schema` | no | Idempotent SQL fragment (§2.6). |
| `purge` | no | Destructive teardown (§2.6). Required if `schema` is present. |
| `mounts` | no | Declared prefixes per tier (§2.3). Declaration is the contract; the loader compares it against what the module actually registers and rejects a mismatch. |
| `extensions` | no | Core extension slots this module mounts into (§2.4). |
| `capabilities` | no | Opaque strings published by `GET /api/v1/public/modules`, for clients (the SPA, the Android app) to feature-detect against. |
### 2.2 The entry point
`server/index.js` exports a single function. It is called once, synchronously, during `app.js`
require — **not** after the database is up.
```js
module.exports = function register(ctx, api) { /* … */ }
```
It must not `await`, must not touch the database, and must not throw for a reason that a retry would
fix. Everything that needs a live database belongs in `onBoot` (§2.5). This constraint is not
stylistic: `scripts/routeManifest.js` and `swagger/swagger.js` both require `app.js` with the pool
pointed at a dead port, and a module that queried at registration time would hang both.
### 2.3 `ctx` — what core hands the module
Every member below exists because a UO file uses it today. Nothing is speculative, and nothing that
module-uo does not need is on the list.
| Member | Signature | Backed by | First real caller |
| --- | --- | --- | --- |
| `ctx.db.query` | `(sql, params?) => Promise<rows>` | `utils/db` | every `*.db.js` |
| `ctx.db.pool` | mariadb pool | `utils/db` | `shardAtlas.db.js` (streamed import) |
| `ctx.log` | `(namespace) => { error, warn, info, debug }`, each `(msg, meta?)` | `utils/logger` | all nine UO utils |
| `ctx.settings.get` | `(key) => Promise<string\|null>` | `model/settings` | `shardAtlas.model` |
| `ctx.settings.set` | `(key, value) => Promise<void>` | `model/settings` | `shardAtlas.model` |
| `ctx.settings.getInstanceName` | `() => Promise<string>` | `model/settings` | `shardIngest.js:84` |
| `ctx.auth.getUserFromRequest` | `(req) => { id, username, role } \| null` | `utils/auth` | `shardVisibility.js` |
| `ctx.push.publish` | `(streamId, { ref?, ownerUserId? }) => Promise<void>` | `utils/pushDispatch:92` | `shardIngest.js:22` |
| `ctx.secretBox` | `{ encrypt(s), decrypt(s) }` | `utils/secretBox` | `uoLinkConfig.model` |
| `ctx.middleware` | `{ requireAuth, requireRole, siteMode, validate, noindex }` | `auth/session.middleware`, `middleware/*` | every UO router |
| `ctx.uploads` | `{ upload, UPLOAD_DIR, MIME_EXT }` | `admin/imageUpload.js` | atlas art import |
| `ctx.posts` | `{ listAll, getById, linkAnnounceJob, markAnnounced }` | `model/posts` | `newsGump.js:108`, `announceWorker.js:58` |
| `ctx.paths.moduleRoot` | absolute path to `modules/<id>/` | loader | atlas art, cliloc files |
| `ctx.moduleId` | the id from `module.json` | loader | log tags, table checks |
Three narrowings from `MODULE_SYSTEM.md` §2.1, all deliberate:
- **`ctx.auth` is one function, not `utils/auth`.** The facade also re-exports `signToken`,
`setAuthCookie` and the TOTP challenge primitives. Minting sessions is core's job; a module that
needs an identity needs to *read* one.
- **`ctx.settings` is three functions, not the model.** The model exports 24 names, most of them
registration/game-signup/app-links policy that is core's business.
- **`ctx.posts` is four functions.** `create`/`update`/`remove` are the CMS, not a module's.
`ctx` is frozen (`Object.freeze`, one level deep) before it is handed over. That is a guard against
accident, not against a hostile module — per `MODULE_SYSTEM.md` §2.2 the boundary is organisational,
not a security boundary.
### 2.4 `api` — what the module registers
The second argument. Every call is synchronous, idempotent-free (calling twice is an error), and
validated at once rather than at first use.
```js
api.registerRoutes({ public: {...}, admin: {...}, player: {...} })
api.registerExtension(slot, router)
api.registerNotificationStreams({ streams, mapEvent })
api.registerAnnounceLeg({ leg, dispatch, classify })
api.onBoot(async (ctx) => {})
api.onShutdown(async () => {})
```
**`registerRoutes(mounts)`** — one `express.Router()` per prefix per tier:
```js
api.registerRoutes({
public: { '/shard': shardRouter, '/atlas': atlasRouter },
admin: { '/shard': adminShardRouter, '/uo-link': uoLinkRouter },
player: { '/shard': playerShardRouter },
})
```
The keys must match `module.json`'s `mounts` exactly. Prefixes are validated `^/[a-z0-9][a-z0-9-]*$`
— one segment, no nesting, no parameters — and rejected on collision with core's own mount table or
with another module's, at registration time. The router is mounted *inside* the tier, so it
structurally cannot reach above its prefix.
**The tier gate is already applied.** A router registered under `admin` sits behind
`noindex, isLoggedIn, requireRole('admin','editor','moderator')` from `router/v1/admin/index.js`;
under `player`, behind `noindex, requireAuth`; under `public`, behind nothing, by design. A module
adds per-route gates on top of that and never re-implements the tier gate.
**`registerExtension(slot, router)`** — the §1.9 case: module routes hanging off a *core* resource.
Only core may declare a slot; a module may only fill one. Exactly one slot exists in v1:
| Slot | Mounted at | Declared by |
| --- | --- | --- |
| `admin.users.detail` | `/api/v1/admin/users/:id` | `router/v1/admin/users.router.js` |
The router receives `req.params.id` from the parent (`mergeParams: true`). Two modules filling the
same slot is a collision and is rejected; core's own routes on the resource always win a path
conflict.
**`registerNotificationStreams({ streams, mapEvent })`** — §1.8's push catalog.
`streams` is an array of `{ id, label, description, scope }` appended to core's catalog (ids are
namespaced `<moduleId>.<name>` and rejected otherwise); `mapEvent(event) => streamId | null` is
called by core's dispatcher for events the module's own code publishes.
**`registerAnnounceLeg({ leg, dispatch, classify })`** — §1.8's news dispatcher.
`leg` is a namespaced id, `dispatch(post) => Promise<void>` delivers, `classify(post) => boolean`
decides whether this leg wants the post. A leg that throws is retried by core's existing per-leg
retry and never blocks another leg.
**`onBoot(fn)` / `onShutdown(fn)`** — §2.5.
### 2.5 Lifecycle
```
require(module) → register(ctx, api) → [routes mounted, app.js require returns]
↓ (server.js, after ensureSchema + seed)
onBoot(ctx) → started
↓ (SIGINT/SIGTERM)
onShutdown()
```
`onBoot` is where the eight `server.js` UO call sites go (`MODULE_SYSTEM.md` §1.7): the atlas and
cliloc refreshes, the market display-name backfill, `uoLinkSocket.start()`, the sidecar probe.
It runs **after** `ensureSchema()` (so the module's own tables exist) and after `seedDefaults()`,
and **before** the HTTP listener binds — a module that must not serve traffic before it has warmed
its cache gets that for free.
`onShutdown` runs before the server closes, in reverse registration order, with a 5-second budget
per module; exceeding it is logged and skipped rather than hanging the process.
Both are individually try/caught. An `onBoot` that throws marks that module `startup_failed`
(§4.4) and the site still comes up — its routes stay mounted but its dispatch guard rejects them
with 503, because a module that failed to warm up serving half-initialised data is worse than a
module that says it is down.
### 2.6 Schema fragments
`schema` is an idempotent `.sql` file replayed by the same `ensureSchema()` that replays core's,
immediately after it, statement by statement, split the same way. It is subject to the same rules
core's file already follows: `CREATE TABLE IF NOT EXISTS`, `ALTER TABLE … ADD COLUMN IF NOT EXISTS`,
no `--` inside a string literal, no `DROP`.
**Table names are namespaced and collision-checked.** New tables must be prefixed `<id>_`. The
loader extracts every `CREATE TABLE IF NOT EXISTS <name>` from the fragment and rejects the module
if a name collides with a core table or with another module's — a wrong `DROP`-free fragment can
still silently adopt someone else's table otherwise.
**module-uo is grandfathered.** Its 27 tables are named `shard_*` (26) and `uo_link_config` (1), and
renaming them is a data migration this workstream explicitly does not do (`MODULE_SYSTEM.md` §1.6
puts the count at 25; the working tree says 27 — see §6.4). They are registered in the loader as an
explicit legacy allowlist keyed to `id: "uo"`, so the prefix rule holds for every module written
after this one.
`purge` is the destructive counterpart, run **only** by the explicit admin purge action, never by
uninstall. Required whenever `schema` is present: a module that can create tables and cannot drop
them leaves an operator with orphaned data and no supported way to remove it.
### 2.7 What a module must not do
- `require` anything outside its own directory except node built-ins and its own `dependencies`.
- Mutate `ctx`, `req.user`, or any object core handed it.
- Register an Express error handler, or any middleware at the app level.
- Read `process.env` for core configuration. Its own config is a `settings` key or its own table.
- Call `process.exit`, install signal handlers, or start a listener.
- Write outside `ctx.paths.moduleRoot` and the upload directory.
### 2.8 The OpenAPI fragment
Every module that registers routes ships `swagger-fragment.json` in its bundle root. Core merges the
fragments of started modules into `/api/docs.json`; the full reasoning and the collision rules are
§6.1a. In short: fully-qualified paths, namespaced schema keys, module CI fails if a registered
route has no path in the fragment, and core wins every key collision.
---
## Part 3 — The client contract
### 3.1 How the chunk gets there
Exactly as `MODULE_SYSTEM.md` §2.6 resolved, and Phase 1's spike is what proves it:
1. Module CI builds `client/dist/entry.js` with Vite in **library mode**, `react`, `react-dom`,
`react-dom/client` and `react-router-dom` declared **external**.
2. Core serves the module directory statically at `/modules/<id>/` — same-origin, so
`script-src 'self'` (`config/csp.js:49`) admits it with no nonce and no inline.
3. `utils/htmlShell.js` injects `<script type="module" src="/modules/<id>/entry.js">` at the
`</head>` rewrite it already performs (line 111), for each **started** module.
4. Before that tag, core has published `window.__rg` (§3.2) from its own bundle. The module's
externals resolve against it.
There is exactly one React instance and core owns it. A module that bundles its own React will
produce two copies of the hook dispatcher and fail at the first `useState`; the externals config in
§3.5 is what prevents it.
### 3.2 `window.__rg`
Populated by core's `main.jsx` **before** it renders, and frozen afterwards.
```js
window.__rg = {
version: '1.0.0', // MODULE_API_VERSION — the same number as the server's
react, // the React namespace
reactDom, // react-dom/client
router, // react-router-dom namespace
registry, // §3.3
ui, // §3.4
api, // §3.5
}
```
A module entry checks `window.__rg.version` against its own `coreApi` range and refuses to register
on a mismatch, logging once — the client-side twin of §1.1, and the reason `version` is here at all.
### 3.3 `registry` — what the module registers
```js
registry.registerRoutes(id, { public: [...], admin: [...], player: [...] })
registry.registerNav(id, { area, items })
registry.registerFeatureProvider(id, namespace, hook)
```
**`registerRoutes`** — arrays of `{ path, element, gate? }`. Paths are relative to the module's
namespace and core prefixes them (`MODULE_SYSTEM.md` §2.8):
| Area | Rendered at | Wrapped in |
| --- | --- | --- |
| `public` | `/<id>/<path>` | `MaintenanceGate` |
| `admin` | `/admin/<id>/<path>` | `RequireAuth` + `AdminLayout` |
| `player` | `/player/<id>/<path>` | `RequirePlayer` + `PlayerPortalLayout` |
`gate` is an optional `{ roles: [...] }`, applied by core as the existing `RoleGate`. A module cannot
supply its own auth wrapper — that is the one place where the client boundary is load-bearing, since
the sidebar and the route table must agree about who may see what.
`App.jsx` stops being a flat static table and becomes core's routes plus
`registry.routesFor(area)`. Registration happens at entry-script evaluation, which is before
`createRoot().render()`, so nothing renders against a half-populated registry.
**`registerNav`** — items interleave into *core* groups (`MODULE_SYSTEM.md` §1.4):
```js
registry.registerNav('uo', {
area: 'admin',
items: [
{ label: 'Shard ops', to: '/admin/uo/shard-ops', group: 'Moderation', order: 30,
roles: ['admin', 'moderator'] },
{ label: 'Shard', to: '/admin/uo/link', group: 'System', order: 10 },
],
})
```
`group` names an existing core group; an unknown group name appends a new group at the end rather
than dropping the item. `order` sorts within the group, core items keeping their current positions.
`feature` (public area only) names a flag resolved by §3.3's provider.
`MOD_PATHS` in `AdminLayout.jsx:109` — today a hardcoded allowlist of two UO paths — becomes a
computation over each item's `roles`, so moderator visibility follows from the registration instead
of from a second list that has to be kept in sync.
The pipeline is unchanged from `THEMING_AND_NAV.md`, with one new first step:
> registered defaults (core **+ modules**) → role/feature filtering → admin overrides → rendered nav
**`registerFeatureProvider`** — core keeps a generic flag context; the module supplies the hook that
fills its namespace (`useShardFeatures` for `uo`). With no module installed the filter is a correct
no-op, because no core nav item carries a `feature` today.
### 3.4 `ui` — the shared component kit
**This is the largest addition Phase 1 makes to the plan, and it is not optional** (§6.2; approved
2026-08-10). The atlas
pages alone import five core modules that are not React and not the router: `PublicLayout`,
`PageHeader`, `Loading` / `ErrorState` / `EmptyState`, and `useAsync`. Without a shared kit a module
either reaches into core's tree (violating the zero-import rule) or ships its own copies, which
means a module page that does not look like the site it is installed in — and drifts further every
time core's layout changes.
The kit is **curated and closed**, not a re-export of `components/`:
| Export | From | Why it is in the kit |
| --- | --- | --- |
| `PublicLayout` | `components/PublicLayout.jsx` | the public chrome; a module page without it is a bare page |
| `AdminPage` | `routes/admin/…` | the admin content frame |
| `PageHeader` | `components/PageHeader.jsx` | title/subtitle furniture |
| `Loading`, `ErrorState`, `EmptyState` | `components/PageState.jsx` | the three states every data page has |
| `useAsync` | `lib/useAsync.js` | the fetch/loading/error hook every data page uses |
| `useAuth`, `useSite` | `contexts/*` | read-only access to session and site settings |
Everything else — tables, chips, tabs, the tiptap editor, dnd-kit — a module bundles itself.
Adding to the kit is a **minor** `MODULE_API_VERSION` bump; changing a kit component's props is a
**major** one. That is a real constraint on core and it is the price of the boundary being worth
anything.
### 3.5 `api` — the request primitive
`client/src/api/client.js` is one 518-line object, and it already carries module namespaces:
`api.atlas` (line 196) and `api.shard` are UO bindings living in core's client. They move out with
the module.
Core exposes the primitive, not the object:
```js
window.__rg.api = { request, ApiError, BASE } // request(path, { method, body, headers, raw })
```
`request` is `client.js`'s existing `req` — same-origin `/api/v1`, `credentials: 'include'`, JSON
in/out, throwing `ApiError(status, message, body)`. A module builds its own namespace over it and
owns the paths it calls, which is correct: it owns the routes at the other end.
### 3.6 Vite library-mode build
The module's `vite.config.js`, and the four externals are the whole contract:
```js
export default defineConfig({
plugins: [react()],
build: {
lib: { entry: 'src/entry.jsx', formats: ['es'], fileName: () => 'entry.js' },
outDir: 'dist',
modulePreload: { polyfill: false }, // same reason as core: no inline bootstrap under CSP
rollupOptions: {
external: ['react', 'react-dom', 'react-dom/client', 'react-router-dom'],
output: { paths: { /* rewritten to window.__rg by the shim below */ } },
},
},
})
```
Rollup's `external` alone emits bare `import 'react'` specifiers, which the browser cannot resolve
without an import map — and CSP forbids the inline `<script type="importmap">` that would provide
one (`MODULE_SYSTEM.md` §1.14). The module therefore ships a two-line shim module that re-exports
from the global, and aliases the four externals to it:
```js
// src/shim/react.js
export default window.__rg.react
export const { useState, useEffect, useMemo, useCallback, useRef, createElement, Fragment } = window.__rg.react
```
**This is the highest-risk mechanical detail in the whole plan and it is exactly what the Phase 1
spike exists to prove.** If it does not hold, §2.6 of the design of record is wrong and the client
half needs rethinking before Phase 2 builds on it.
---
## Part 4 — The loader's obligations
### 4.1 Synchronous, filesystem-sourced
`app.js` scans `modules/*/module.json` with `fs.readdirSync` at require time and mounts what it
finds (`MODULE_SYSTEM.md` §1.12). The database is not consulted. `MODULES_DIR` defaults to
`<repo>/modules` and is overridable by env for tests and for the Docker volume mount.
### 4.2 Order
Alphabetical by `id`, deterministically. There is no dependency resolution between modules (§2.0 of
the design of record puts it out of scope) and alphabetical order is the honest way to say so — any
other order would imply a precedence that is not being computed.
### 4.3 Validation, in this order
1. `module.json` parses; no unknown keys; `id` matches the directory.
2. `coreApi` satisfied by `MODULE_API_VERSION`.
3. Declared `mounts` prefixes are well-formed and collide with nothing.
4. Declared `extensions` slots all exist.
5. `schema`/`purge` files exist and are readable; table names are namespaced or allowlisted.
6. `require(server)` succeeds and exports a function.
7. `register(ctx, api)` returns without throwing, and registers exactly what `module.json` declared.
A failure at any step is that module's failure and nobody else's.
### 4.4 `startup_failed` is a state, not a crash
Per `MODULE_SYSTEM.md` §2.4, the loader try/catches the **entire** lifecycle — require, validation,
registration, schema replay, `onBoot` — and any failure marks the module `startup_failed` with the
reason recorded in `installed_modules`, visible in the admin panel, recoverable without shell
access. The site comes up.
Two sub-cases differ, and the difference matters:
| Failure before routes are mounted | Failure after (schema, `onBoot`) |
| --- | --- |
| routes and nav are simply absent | routes stay mounted; the dispatch guard returns **503** |
The second is what keeps the URL surface deterministic and generatable: `routes.manifest.json` must
not depend on whether a module's boot hook happened to succeed on the machine that generated it.
### 4.5 The disabled guard
A module disabled in `installed_modules` is *mounted and guarded*, never unmounted — a one-line
`if (!enabled) return res.status(404)` ahead of its tier mount. Same reason: the URL surface is a
property of the filesystem, not of a database row.
---
## Part 5 — Enforcement
### 5.1 Zero internal imports (CI, module repo)
The acceptance test for the whole contract. In the module's own CI:
```
grep -rE "require\(['\"]\.\./\.\./|from ['\"]\.\./\.\./\.\./" server/ client/src/
```
— refined to "no relative path that escapes the module root", plus a check that the only bare
specifiers in the client bundle are the four declared externals. A hit fails the build.
### 5.2 Zero UO identifiers in core (CI, website repo)
Phase 3's acceptance criterion 1: no `shard`, `uoLink`, `cliloc`, `atlas` or `towncrier` outside
`modules/`, as a CI grep test rather than a review promise.
### 5.3 Zero-line route manifest diff (CI, both repos)
`npm run routes:manifest -- --check` in core; the module generates and freezes its own manifest in
its own repo, using the same script pointed at a core+module app. Phase 2 must produce a zero-line
diff in core's; Phase 3 moves the UO entries out of core's and into module-uo's, which is the one
diff the whole workstream is allowed.
---
## Part 6 — Amendments to MODULE_SYSTEM.md
Four things the survey found that the design of record gets wrong or does not cover. The first
needs a decision.
### 6.1 OpenAPI generation does not survive a dynamic loader — **settled: fragment merge**
`MODULE_SYSTEM.md` §1.12 treats `scripts/routeManifest.js` and `swagger/swagger.js` as the same
problem, because both require `app.js` with no database. They are not the same problem.
- **`routeManifest.js` walks the live Express stack** (`app._router.stack`, line 175). It is runtime
introspection and a filesystem-scanning loader is invisible to it in the best way: whatever got
mounted, it sees.
- **`swagger/swagger.js` is static analysis.** `swagger-autogen` is handed `routes = ['./src/app.js']`
(line 29) and *parses the source text*, following `app.use(...)` to the required file. A
`require(path.join(dir, manifest.server))` inside a `for` loop is not statically resolvable. Module
routes will be **absent from `swagger-output.json`** — silently, with no error.
That collides directly with CLAUDE.md's standing rule: *never ship a route that isn't in the OpenAPI
spec*. Three ways out:
| Option | How | Cost |
| --- | --- | --- |
| **A. Fragment merge** ✅ | Module CI runs swagger-autogen against its own `server/index.js` and ships `swagger-fragment.json` in the bundle. Core deep-merges the fragments of started modules into `/api/docs.json` at request time. | one merge helper in core (~40 lines); the module owns its own spec, which matches "one repo, one bundle" |
| B. Glob the modules dir | Core's `swagger.js` adds `modules/*/server/index.js` to `routes` when present. | core's *committed* spec then depends on which modules the developer had checked out — a spec that differs per machine |
| C. Hand-write module paths into core's spec | — | a second source of truth; drifts on the first module release |
**Decided 2026-08-10: A.** It is the only one that keeps the spec correct on an operator's box,
where core is a prebuilt image and the module arrived afterwards. The obligation this puts on a
module is §2.8; the one it puts on core is Phase 2 item 2.
### 6.1a The obligation, on each side
**Module:** a `swagger-fragment.json` in the bundle root, generated by its own CI with the same
`swagger-autogen` tooling pointed at its own entry point, carrying only `paths`, `tags` and
`components.schemas`. Its paths must be fully qualified (`/api/v1/public/atlas/creatures`), because
the module knows its own mount prefixes and core does not re-derive them. Its `components.schemas`
keys are namespaced (`UoAtlasCreature`, not `AtlasCreature`) so two modules cannot collide in the
merged spec. CI fails the module build if a route it registers has no path in its fragment — the
per-module form of "never ship a route that isn't in the spec".
**Core:** `/api/docs.json` merges the fragments of **started** modules over its own committed spec at
request time (cached, invalidated on a module state change). Merge is shallow-per-section and
**core always wins a key collision** — a module cannot redefine a core path, tag or schema by
shipping one with the same name; the collision is logged and the module's version dropped.
`swagger-output.json` itself stays exactly what core's own routes generate, so `npm run swagger`
remains reproducible on any machine regardless of what is installed.
### 6.2 The client contract is much larger than §2.1 says
§2.1 lists three client registration calls and nothing else, implying React and the router are all a
module needs. The atlas pages disprove it: `Atlas.jsx` imports `PublicLayout`, `PageHeader`,
`PageState`'s three states, `useAsync` and `api` — five core modules beyond React, on the *smallest*
UO page. Hence §3.4's curated UI kit and §3.5's request primitive. This is an addition to the
contract, not a change of direction, but it makes core's component API a versioned surface, which it
has never been before.
### 6.3 `shardVisibility` is module-owned, and core's nav depends on it
`utils/shardVisibility.js` is a UO util that provides `requireFeature` and `project` — and the atlas
routes, the spike target, are gated by it (`atlas.router.js:25`). So the spike carries
`shardVisibility` plus the `shardVisibility` and `shardLinks` models with it, not just the atlas
files. Two consequences:
- On the **server** this is fine: `requireFeature` becomes module-internal middleware, and only
`PUBLIC_KINDS` crosses the boundary — already handled by §1.8's `registerNotificationStreams`
inversion.
- During the **spike** there will be two copies of `shardVisibility` in one process (the module's,
and core's for the not-yet-extracted shard routes) with two independent 5-second caches over the
same table. Functionally identical, and a spike-only artifact that Phase 3 resolves by moving the
original. Recorded so it is not mistaken for a design flaw.
### 6.4 Two counts in §1.6 and §2.7 are off
- **27 UO tables, not 25**: 26 `shard_*` plus `uo_link_config`. §1.6 says "the 24 `shard_*` tables
plus `uo_link_config`".
- **The atlas spike is 6 routes, not 5**: `/creatures`, `/creatures/:slug`, `/regions`,
`/landmarks`, `/champions`, `/meta`.
Neither changes a decision; both are corrected here rather than left to be tripped over when the
extraction is counted against the plan.

View File

@@ -1,520 +0,0 @@
# The Module System — design of record
**Status:** approved design, not yet implemented. Every decision in Part 3 has been settled with the
org lead; Part 1 records what was verified against the working trees on 2026-08-10, including the
places the original draft was wrong.
**The normative contract is [`MODULE_API.md`](MODULE_API.md)** (Phase 1). This document decides what
the module system *is*; that one decides exactly what a module may call. Where the two differ, that
one wins — its Part 6 lists the four places it amends this document.
**Goal.** Turn Runic Gateway from a UO/ServUO-specific platform into a game-agnostic one. The core
architecture is unchanged — sidecar → website → browser. What changes is that game-specific
behaviour (routes, tables, screens, nav) leaves the core website and becomes an installable
**module**. An operator installs the base site, installs the module for their game, and restarts.
**The model is WordPress plugins, not a build system.** An operator never compiles anything to
deploy a module. The module's own CI publishes it prebuilt; the operator drops it in and enables it.
This single constraint drives most of Part 2.
**Out of scope.** The sidecar's per-game protocol adapters. `link/`, `servuo-plugins/` and
`installer/` are the shard side and stay independent of this work — the installer runs on the shard
host and by design never contacts the website (`installer/src/cli.rs:162`). Also out of scope: the
Android app, which gets its own plan covering module discovery and multi-server profiles; this plan
only owes it the capability endpoint in §2.5.
---
## Part 1 — What is actually there
### 1.1 The parts that are already clean
The extraction is closer to a folder move than a teardown, and that is not an assumption:
- **Models.** `server/src/model/` holds 31 directories; exactly 8 are UO — `shardAtlas/`,
`shardClilocs/`, `shardEvents/`, `shardLinks/`, `shardMarket/`, `shardState/`, `shardVisibility/`,
`uoLinkConfig/`. No mixing with `users/`, `posts/`, `pages/`, `wiki/`, `settings/`.
- **Routers.** All 13 UO router/controller files are single-purpose, with no shared code:
`admin/shard.router.js`, `admin/shardAtlas|shardClilocs|shardOps|shardVisibility.controller.js`,
`admin/uoLink.router.js` + `.controller.js`, `public/atlas.router.js` + `.controller.js`,
`public/shard.router.js` + `.controller.js`, `player/shard.router.js` + `.controller.js`.
- **Mount points.** `router/v1/{public,admin,player}/index.js` are pure mount tables that declare no
routes of their own. Module mounting drops straight in with no restructuring.
- **The API surface is small and knowable.** The nine UO `utils/` files import only four things from
core: `settings.model`, `logger`, `auth`, `pushDispatch`. That is the empirical basis for §2.1 —
the contract is derived from what the real code uses, not designed speculatively.
### 1.2 Route prefixes: one flat module prefix is impossible
A module cannot be handed a single pre-scoped router at, say, `/api/v1/game/uo`, because the existing
UO URLs live under three different access tiers — `/api/v1/public/shard/*`,
`/api/v1/admin/shard/*`, `/api/v1/player/shard/*` — and those URLs are protected by
`routeManifest.test.js` and consumed by three shipped clients (SPA, Android app, Discord bot).
**Resolved:** a module owns a *named slot inside each tier*. It still only ever holds a pre-scoped
`express.Router()` and structurally cannot reach above its mount point; it simply holds one per tier.
```json
"mounts": {
"public": ["/shard", "/atlas"],
"admin": ["/shard", "/uo-link"],
"player": ["/shard"]
}
```
The loader rejects a prefix collision between two modules, or between a module and core, at
registration time. That check is unavoidable; the per-request routing boundary is not left to the
module's good behaviour.
### 1.3 There is no server-side nav list, and there must not be one
`server/src/utils/navOverrides.js` (lines 13–21) refuses this explicitly:
> **What this module cannot check, deliberately: whether a `to` exists.** The three base NAV arrays
> are client constants (SiteHeader.jsx, AdminLayout.jsx, PlayerPortalLayout.jsx). Shipping a copy of
> them to the server would create a second source of truth for navigation that drifts the first time
> a route is added…
Confirmed: `export const NAV` lives in `client/src/components/SiteHeader.jsx:23`,
`client/src/routes/admin/AdminLayout.jsx:56`, `client/src/routes/player/PlayerPortalLayout.jsx:41`.
The server validates override *shape* and nothing else.
**Resolved:** nav registration is **client-side**, performed by the module's own client bundle
against a core-provided registry. No server nav API is introduced, and
`THEMING_AND_NAV.md`'s override model is untouched. The resulting pipeline is:
> registered defaults (core + modules) → role/feature filtering → admin overrides → rendered nav
### 1.4 Module nav items interleave into *core* groups
Appending a "UO" group is not enough. Today's UO items sit inside core groups in `AdminLayout.jsx`:
group **Moderation** holds `/admin/shard-ops` and `/admin/houses`; group **System** holds
`/admin/shard`, `/admin/shard-visibility`, `/admin/shard-atlas`; the unnamed footer group holds
`/admin/characters`. `MOD_PATHS` (line 109) additionally hardcodes two UO paths as
moderator-visible.
**Resolved:** nav registration takes a target group and order (`{ group: 'Moderation', order: 30 }`),
and `MOD_PATHS` becomes a `roles`-derived computation rather than a path allowlist.
### 1.5 The public nav's feature-gating mechanism is itself a shard system
Ten of the sixteen entries in `SiteHeader.jsx`'s NAV carry a `feature:` key (`status`, `champs`,
`guilds`, `governors`, `houses`, `ruleset`, `atlas`, `leaderboards`, `market`), resolved by
`useShardFeatures()` against `/api/v1/public/shard/features` — the shard visibility system.
Extracting the module removes the provider that core's own nav filter depends on.
**Resolved:** core keeps a generic feature-flag context with a **registerable provider**; the module
registers its `useShardFeatures` for its own namespace. No core nav item carries a `feature` today,
so with no module installed the filter is a correct no-op.
### 1.6 There is no migration system to model a module migration runner on
`server/db/schema.sql` is a single idempotent file — 1,380 lines, 67 tables — replayed in full on
every boot by `ensureSchema()` (`src/utils/db.js:48`), split on `;` and executed statement by
statement. Schema evolution uses `ALTER TABLE … ADD COLUMN IF NOT EXISTS` / `MODIFY COLUMN`
(from line 1322). There is **no version table, no runner, no migrations directory**.
A module-scoped migration runner would therefore be the *first* migration system in the codebase,
and would leave core and modules on two different schema models.
**Resolved:** modules ship a `schema.sql` fragment, replayed idempotently by the same
`ensureSchema()` immediately after core's. Forward-only falls out for free — it is all an idempotent
replay can be. Install and upgrade become the same operation. Uninstall stops the fragment being
replayed; **purge** is a separate, explicit, destructive admin action that runs the module's
`purge.sql`. A real migration runner, covering core *and* modules together, is a legitimate future
workstream; it is not a prerequisite for this one.
Twenty-seven of the tables move with the module: the 26 `shard_*` tables plus `uo_link_config`.
(This said 25 when written; the working tree was recounted in Phase 1 — see
[`MODULE_API.md`](MODULE_API.md) §6.4.)
### 1.7 Boot and shutdown is a lifecycle gap
`server/src/server.js` holds eight UO call sites that routes, nav and schema do not cover:
| Line | Call |
| --- | --- |
| 92 | `shardAtlas.refreshOnBoot()` |
| 99 | `shardClilocs.refreshOnBoot()` |
| 107 | `shardMarket.refreshDisplayNames()` (conditional on the cliloc import result) |
| 130 | `uoLinkSocket.start()` |
| 131 | `checkUoLink()` — plus the whole function at 147–169 |
| 179 | `uoLinkSocket.stop()` |
| 180 | `shardBroadcast.closeAll()` |
| 7–19 | five top-level `require`s of UO modules |
**Resolved:** the API surface includes `onBoot(ctx)` and `onShutdown()`, each individually
try/caught by the loader per §2.4.
### 1.8 Three core files are genuinely entangled
Everything else is a folder move. These are not:
1. **`src/config/notificationStreams.js`** — the push stream catalog. `mapShardEvent()` and most of
`STREAMS` are shard-derived, and it imports `PUBLIC_KINDS` from `utils/shardBroadcast`. Push
*infrastructure* is core; this *catalog* is module content.
→ `registerNotificationStreams({ streams, mapEvent })`.
2. **`src/utils/pushDispatch.js`** — core infrastructure, but `fromShardEvent()` (line 112) requires
the `shardLinks` model (line 21) and `mapShardEvent` (line 23).
→ invert: `publish()` stays core, `fromShardEvent` moves into the module and calls it.
3. **`src/utils/announceWorker.js`** — the news dispatcher, with two delivery legs: Discord (core)
and town crier (module, via `uoLinkClient.postTownCrier`, line 36).
→ `registerAnnounceLeg({ leg, dispatch, classify })`.
`src/utils/newsGump.js` is module-side (news → in-game gump) and moves whole.
### 1.9 A fourth mount shape: module routes under a core resource
`router/v1/admin/users.router.js` mounts `usersShard.controller.js` at six UO sub-paths of a **core**
resource — `/:id/shard/accounts|sales|houses|online|standing` and `DELETE /:id/shard/link/:account`.
And `GET /api/v1/admin/users/:id` (line 159) is itself served by `usersShard.getUser`, which is core
semantics that ended up in the UO controller by proximity.
**Resolved, two parts:** (a) `getUser` moves back into `admin.controller.js`; (b) core declares a
narrow **extension slot** on `/admin/users/:id` that the module mounts into, so core never learns
what "shard" means and all six URLs are preserved. Only core may declare an extension slot; a module
may not invent one.
### 1.10 The Discord bot has no UO logic
The draft listed the bot's "UO-specific event/moderation logic" as an extraction candidate. Grepping
`website/bot/src` for `uo|ultima|shard|towncrier|governor|vendor` returns **zero matches**. The bot's
only site coupling is `src/site/siteApiClient.js`. There is nothing to extract.
### 1.11 The installer is not, and will not become, the delivery path
Two independent reasons, and the decision is that website and installer stay independent:
1. **The installer runs on the shard host and never contacts the website** — `src/cli.rs:162`: *"The
installer never contacts your website, never deletes anything from your…"*. A website module is a
website-host artifact.
2. **`Bundle` is hardcoded to exactly two components.** `installer/src/bundle.rs` declares
`pub link: LinkComponent` and `pub overlay: OverlayComponent`, both non-`Option`, alongside a
single top-level `protocol: u32` and `SUPPORTED_SCHEMA: u32 = 1`. A third artifact type would be
a schema-2 bump — and the sidecar/overlay protocol number has nothing to say about a website
module anyway.
Note also that **there is no SHA256SUMS trust anchor** anywhere in the installer, contrary to the
draft. The real model is a per-asset `sha256` field inside a bundle JSON fetched anonymously over
HTTPS from the `bundles` branch. No signatures. The *shape* is worth reusing; the name was wrong.
### 1.12 Modules must mount synchronously, from the filesystem
`server/scripts/routeManifest.js:38` and `server/swagger/swagger.js:29` both walk the Express stack by
`require`-ing `src/app.js` **with no database connection** — the manifest script deliberately points
the pool at a dead port. A DB-driven async loader would make module routes invisible to both,
silently breaking the frozen-URL-surface test and shipping undocumented routes.
**Resolved:** the **filesystem is the mounting source of truth.** `app.js` synchronously scans
`modules/*/module.json` at require time and mounts what it finds. The `installed_modules` row carries
state and metadata (version, installed-at, `startup_failed` reason, admin enable/disable) and is
reconciled against the filesystem once the DB is up. A module disabled in the DB is skipped by a
one-line dispatch guard rather than being unmounted, so the URL surface stays deterministic and
generatable.
### 1.13 The client seam is a route registry, not an admin-panel loader
`client/src/App.jsx` is a flat 235-line static route table, and UO routes appear in all three areas —
public (`/site/shard`, `/site/shard/activity`, `/site/governors`, `/site/houses`, `/site/atlas`,
`/site/atlas/:slug`, `/site/market`, `/site/market/vendors/:serial`, plus champs, guilds, rules,
leaderboards), admin (`shard`, `shard-visibility`, `shard-atlas`, `shard-ops`, `houses`,
`characters`, `characters/:serial`) and player. Nav is one consumer of that registry, not the
mechanism itself.
### 1.14 Production is a prebuilt, pull-only image — and that is the binding constraint
`website/Dockerfile` bakes `client/dist` at image build time, and `docker-compose.yml` has no
`build:` stanza at all (deliberately: *"a production host can only ever pull, never accidentally
build"*). Combined with the requirement that **an operator must never build anything to deploy a
module**, this rules out build-time inclusion of module client code, which the draft had as its
default.
It also rules out import maps as the shared-dependency mechanism: `config/csp.js:49` sets
`'script-src': ["'self'"]` with no `'unsafe-inline'`, and an import map must be an inline
`<script type="importmap">`.
**Resolved** — see §2.6. The path that survives all three constraints is: the module's CI ships a
**prebuilt ESM chunk**, core hands it React through a **global** rather than an import map, and
`htmlShell.js:111` injects a **same-origin** `<script type="module" src>`, which `'self'` already
allows.
---
## Part 2 — The plan
### 2.0 Scope and non-goals
Out of scope unless Phase 1 turns up a concrete reason otherwise: hot module reload; sandboxing
beyond the boundary stated in §2.2; inter-module dependency resolution; a module marketplace or
discovery UI; automatic data rollback beyond the forward-only model in §1.6. Install and uninstall
require a controlled **restart** — never a rebuild.
Added: **no installer changes at all** (§1.11), and **no Android changes in this workstream** beyond
the one consequence recorded in §2.8.
### 2.1 The API surface, derived from real dependencies
Taken from what the UO code actually imports today. Nothing speculative — if module-uo does not use
it, it is not on the list.
**Server — the `ctx` handed to a module's entry point**
| Member | Backed by | Why it is here |
| --- | --- | --- |
| `ctx.db` | `utils/db` (`query`, `pool`) | every `*.db.js` |
| `ctx.settings` | `model/settings/settings.model` | `shardIngest.js:20` |
| `ctx.log(namespace)` | `utils/logger` | all nine UO utils |
| `ctx.auth` | `utils/auth` | `shardVisibility.js:26` |
| `ctx.push.publish()` | `utils/pushDispatch` | `shardIngest.js:22` |
| `ctx.secretBox` | `utils/secretBox` | `uoLinkConfig` model |
| `ctx.middleware` | `requireAuth`, `requireRole`, `siteMode`, `validate` | every UO router |
| `ctx.uploads` | `admin/imageUpload.js` | atlas art import |
| `ctx.posts` | `model/posts/posts.model` | `newsGump.js`, announce legs |
**Server — what a module registers**
`registerRoutes(mounts)` (§1.2) · `registerExtension(slot, router)` (§1.9) ·
`registerNotificationStreams({ streams, mapEvent })` (§1.8) ·
`registerAnnounceLeg({ leg, dispatch, classify })` (§1.8) · `onBoot(ctx)` / `onShutdown()` (§1.7).
**Client — what a module registers**
`registerRoutes({ public, admin, player })` (§1.13) ·
`registerNav({ nav, group, order, feature })` (§1.3, §1.4) ·
`registerFeatureProvider(namespace, hook)` (§1.5).
**The acceptance test for the whole contract:** `module-uo` runs with **zero** `require`/`import`
reaching outside its own directory. Any gap extends the surface *before* extraction proceeds.
### 2.2 What the module boundary is, and is not
A module runs in the same Node process with full access. The boundary is a **code-organisation and
distribution boundary, not a security boundary** — which is fine for a self-hosted operator
installing software they chose, the same trust category as running its schema fragment. What makes
it worth having is that modules interact with core through a *defined* surface, so a core refactor
cannot silently break a module. Hence the zero-internal-imports rule above, enforced in CI rather
than by review.
### 2.3 Module packaging — one repo, one bundle
**`RunicGateway/Module-uo`** — `https://gitea.whitlocktech.com/RunicGateway/Module-uo.git`, note the
capital `M`, matching `Android-app`'s casing rather than the lowercase directory name. The repo
exists but is **empty** as of 2026-08-10: no branches, no initial commit. Its first commit needs the
usual scaffolding — `README.md`, `LICENSE.md` (GPL-3.0-or-later), `CONTRIBUTING.md` with the
AI-disclosure clause, the PR template, and CI.
Server and client halves live side by side and version together, so a route and the screen that
calls it can never be mismatched:
```
RunicGateway/Module-uo
module.json id, version, coreApi range, mounts, extensions
server/ routers, controllers, models, utils
server/db/schema.sql fragment replayed by ensureSchema()
server/db/purge.sql destructive, only ever run by an explicit purge
client/src/ route components, nav registrations, feature provider
client/dist/ PREBUILT ESM chunk, published by module CI
```
Release artifact: `module-uo-<version>.tar.gz` plus a manifest carrying its `sha256`.
The module's **id** is `uo` — that is what appears in `module.json`, in `installed_modules`, in the
`modules/<id>/` path and in the URL segment. `Module-uo` is the repository; `module-uo` elsewhere in
this document names the module and its artifact, not the repo.
`module.json` declares a `coreApi` semver range, checked at boot against a `MODULE_API_VERSION`
constant in core; a mismatch fails **loudly** rather than silently. This is a separate number from
`PROTOCOL_VERSION`, which versions the shard wire and says nothing about a website module.
### 2.4 The module state machine
`installed → enabled → started`, with `disabled` and `startup_failed` as recoverable states.
**A module that fails to load must never take the site down.** The loader catches failures across the
module's entire lifecycle — require, schema fragment, router construction, registration calls,
`onBoot` — not merely those that surface after a router object was returned. Any failure at any point
marks that one module `startup_failed`, records the reason, and the site comes up with that module's
routes and nav absent. `startup_failed` is recoverable from the admin panel — disable, retry, or roll
back to the previous version — with no shell access to the box.
### 2.5 Install, uninstall, purge
Modules live on a **mounted volume**, not in the image — the same treatment `uploads` already gets in
`docker-compose.yml`. That is what makes the WordPress model work against a pull-only image.
**Install:** admin selects the module → bundle downloaded from the module repo's release and verified
against its `sha256` → unpacked into `modules/<id>/` on the volume → `installed_modules` row written →
**restart**. On boot the loader scans the filesystem (§1.12), validates prefixes, mounts, replays the
schema fragment, runs `onBoot`, and each module reaches `started` or `startup_failed`.
Nothing is compiled at any point. The operator restarts; they never build.
**Uninstall** (default, non-destructive): row set to `disabled`, directory removed, restart. The
module's tables and data are **retained**. **Purge** is a separate, explicit, destructive action that
runs `purge.sql`; it is never bundled into uninstall.
**Surfaces:** the admin panel, and the Docker environment under `website/` — a declarative module set
resolved at container start from the mounted volume, so a compose-managed host is not driven by
clicking. Both paths write the same `installed_modules` row and neither requires a build step.
### 2.6 How the client half loads
This is the piece §1.14 constrains hardest. Three requirements had to hold at once: the operator
builds nothing, production pulls a prebuilt image, and `script-src 'self'` forbids inline script.
1. **The module's CI builds its client half** with Vite in library mode, declaring `react`,
`react-dom` and `react-router-dom` as **externals**. The module never bundles its own React —
there is exactly one React instance, owned by core.
2. **Core exposes the shared dependencies on a global** before mount — `window.__rg = { react,
reactDom, router, registry }` — and the module's externals resolve to it. A global, not an import
map, precisely because an import map must be inline and CSP forbids that.
3. **`htmlShell.js` injects the module's entry script.** It already rewrites `</head>`
(`utils/htmlShell.js:111`), so this is an extension of a working mechanism, not a new one. The tag
is `<script type="module" src="/modules/uo/entry.js">` — same-origin, so `'self'` passes with no
nonce and no inline.
4. **The SPA reads `/api/v1/public/modules`** to learn what to load, then registers routes, nav and
its feature provider through `window.__rg.registry`.
Phase 1 prototypes exactly this before anything is committed to it (§2.7).
### 2.7 Phases
**Phase 0 — unblock CI and scaffold the repo.** Land the one-line `pr-checks.yml` trigger fix on
`website` `main` (§2.9), cut `edge` from `main`, and give `Module-uo` its initial commit (§2.3).
Nothing else can be trusted until the first of these is done.
**Phase 1 — API contract + spike (blocking).** Merge this document. Write the contract at
[`docs/website/MODULE_API.md`](MODULE_API.md) — **done**; it amends this document in four places,
listed in its Part 6, one of which (OpenAPI generation, §6.1 there) needs a decision before Phase 2
starts. Then a throwaway spike on an unmerged branch moving **`/api/v1/public/atlas/*`** behind the
proposed surface — the smallest honest test: six routes, DB-backed, no sidecar, no SSE, one boot
hook. The spike must *also* prove the §2.6 chunk load end to end, since that is the highest-risk
decision in the plan. Exit criteria: no internal-file imports, `npm run routes:manifest` produces a
zero-line diff, and the chunk loads under the enforced CSP.
**Phase 2 — Core scaffolding, no behaviour change.** One PR each, in order:
1. `installed_modules` table + the §2.4 state machine.
2. `src/modules/loader.js` — synchronous filesystem scan, manifest validation, prefix-collision
rejection, per-module try/catch across the whole load path, mounting into the tier routers.
3. `ensureSchema()` extended to replay module fragments after core's.
4. The three de-entanglement registries (§1.8), with core still the only registrant.
5. Boot/shutdown hook dispatch in `server.js`, likewise.
6. `GET /api/v1/public/modules` — installed ids, versions and capabilities, shaped like the existing
branding/site-settings endpoint. The SPA needs it to know what to load; the Android plan consumes
the same endpoint.
7. Client `src/modules/registry.js`, the `window.__rg` shared-dependency global, and the
`htmlShell` script injection — empty registry, no visible change.
8. `MOD_PATHS` → `roles`-derived (§1.4); the generic feature-provider seam (§1.5).
9. `docker-compose.yml` gains the `modules` volume.
Exit criterion: `routes.manifest.json` diff is zero lines and every existing test passes. If Phase 2
changes one URL, it is wrong.
**Phase 3 — Extract `module-uo`.** Moves out of `website/`: the 8 model directories and their 25
tables; the nine UO `utils/` files plus `newsGump.js`; the 13 router/controller files;
`scripts/importSpawnAtlas.js` and `db/spawnAtlas.art.json`; `usersShard.controller.js` **minus
`getUser`** (§1.9); the shard-derived half of `notificationStreams.js` and the town-crier leg of
`announceWorker.js`; and on the client, roughly twenty route components, their nav registrations and
`useShardFeatures`.
Acceptance, all four required:
1. **Zero UO identifiers in core** — no `shard`, `uoLink`, `cliloc`, `atlas` or `towncrier` outside
`modules/`. Enforced by a CI grep test, not by review.
2. **Zero internal-file imports** from `module-uo` into core.
3. **`routes.manifest.json` API diff is zero lines**, except the deliberate `GET /admin/users/:id`
ownership move, which changes no URL. After extraction the core manifest no longer contains UO
routes — `module-uo` generates and freezes its own in its own repo.
4. **A written `module-rust` dry run** — manifest, mounts, nav entries, one notification stream — not
implemented, to prove the contract generalises before more is built on it.
**Phase 4 — Delivery.** The admin-panel Modules screen (install, enable, disable, retry, purge,
`startup_failed` with its recorded reason) and the Docker-environment path from §2.5. Deliberately
last, so loader, packaging, schema and chunk-loading problems are not all being debugged at once.
### 2.8 SPA URL namespacing — a deliberate break
**Decision: module pages are namespaced, and old paths are not redirected.** The site is not public
yet, so bookmarks, inbound links and configured nav overrides carry no real weight. This buys a
visible boundary in the URL rather than a hidden one.
The rule is that a module owns one path segment wherever it appears:
| Today | After |
| --- | --- |
| `/site/shard`, `/site/atlas`, `/site/market`, `/site/governors`, … | `/uo/shard`, `/uo/atlas`, `/uo/market`, `/uo/governors`, … |
| `/admin/shard-ops`, `/admin/shard`, `/admin/shard-visibility` | `/admin/uo/shard-ops`, `/admin/uo/link`, `/admin/uo/visibility` |
| player shard screens | `/player/uo/…` |
**API URLs are not affected** — they keep their exact paths per §1.2, so the Android app and the
Discord bot need no change for the API.
Two consequences, both accepted:
- **Saved nav-override rows are keyed by `to`** (`utils/navOverrides.js`), so any stored
`nav_public` / `nav_admin` / `nav_player` customisation stops applying and must be redone. No
migration is written.
- **`android-app/.../ui/navigation/NavPaths.kt` maps SPA paths to native screens** and holds ten
`/site/*` constants that will no longer resolve. That is one small Android PR, folded into the
separate Android module plan. App Links verification itself is unaffected — the manifest's intent
filters only cover `/mobile/callback` and `auth/callback`.
### 2.9 Branch strategy — `edge`, then one cutover
All website work lands on an **`edge`** branch and reaches `main` as a single cutover at the end,
the same shape used for [protocol v3](../link/v3.md) and the Android theming workstream. Nothing
half-extracted is ever on `main`: a core that has grown a module loader but not yet lost its UO code
is a coherent state, and a core mid-extraction is not.
`edge` does not exist on `website` today — the protocol v3 cutover landed and the branch was cleaned
up, so it is cut fresh from `main`. `Module-uo` develops on its own `main` from its first commit;
it has no cutover to perform, since nothing depends on it until the website cutover lands.
**Phase 0, and it blocks everything: the CI trigger.** `website/.gitea/workflows/pr-checks.yml`
declares:
```yaml
on:
pull_request:
branches: [main]
```
So a PR into `edge` runs **no checks at all** — no server tests, no client build, no bot install.
This is the same trap that let all nine Android M12 phase PRs merge with zero CI. It matters more
here than it did there, because Phase 2's exit criterion *is* a CI result: a zero-line
`routes.manifest.json` diff and a passing test suite. Running the whole workstream blind and
discovering the breakage at cutover is the expensive version of this.
The fix is one line — `branches: [main, edge]` — and it must land on `website` `main` **before** the
first module PR, not alongside it. `build-images.yml` is untouched: it triggers on push to `main`, so
images are published and production rolls at the cutover and at no point before it, which is correct.
### 2.10 Process obligations
Every server-side PR runs `npm run swagger`, `npm run routes:manifest` (the diff is reviewed, not
merely regenerated) and `npm test`, and carries a matching edit to `BACKEND_DESIGN.md`. Module
documentation aggregates in this repo under `docs/modules/<id>/` rather than living in module repos.
Conventional Commits, the AI-disclosure trailer, branches cut from an up-to-date `main`.
---
## Part 3 — Settled decisions
| # | Decision | Where |
| --- | --- | --- |
| 1 | Modules ship idempotent `schema.sql` fragments; no migration runner is built | §1.6 |
| 2 | Website and installer stay independent; delivery is website-side only | §1.11, §2.5 |
| 3 | Core declares an extension slot on `/admin/users/:id`; all six URLs preserved | §1.9 |
| 4 | Phase 1 spike targets `/api/v1/public/atlas/*` | §2.7 |
| 4a | The contract lives in [`MODULE_API.md`](MODULE_API.md); it is normative where the two differ | §2.7 |
| 4b | Modules ship an OpenAPI **fragment**; core merges started modules' fragments into `/api/docs.json` | API §6.1 |
| 4c | Core exposes a **curated, closed** UI kit + request primitive on `window.__rg`, versioned by `MODULE_API_VERSION` | API §3.4 |
| 5 | Install surfaces: admin panel and the Docker environment; never a build step | §2.5 |
| 6 | One repo, one bundle — server and client halves version together | §2.3 |
| 7 | Android app is a separate plan; core owes it `/api/v1/public/modules` | §2.5, §2.7 |
| 8 | SPA pages namespaced: `/uo/*`, `/admin/uo/*`, `/player/uo/*` | §2.8 |
| 9 | Clean break — no redirects, no nav-override migration; site is not public yet | §2.8 |
| 10 | Client half loads as a prebuilt ESM chunk with React shared via a core global | §2.6 |
| 11 | Website work lands on `edge` and reaches `main` as one cutover at the end | §2.9 |
| 12 | The module repo is `RunicGateway/Module-uo`; the module id is `uo` | §2.3 |

View File

@@ -132,7 +132,6 @@ website/
│ │ │ │ ├── RecoveryCodesPanel.jsx
│ │ │ │ ├── TrustedDevicesPanel.jsx
│ │ │ │ └── TrustLimitModal.jsx
│ │ │ ├── BrandLogo.jsx
│ │ │ ├── CharacterSheet.jsx
│ │ │ ├── CharacterStats.jsx
│ │ │ ├── CreateGameAccountForm.jsx
@@ -141,7 +140,6 @@ website/
│ │ │ ├── MaintenanceGate.jsx
│ │ │ ├── Modal.jsx
│ │ │ ├── MoonDot.jsx
│ │ │ ├── NavDropdown.jsx
│ │ │ ├── PageHeader.jsx
│ │ │ ├── PageState.jsx
│ │ │ ├── PlayersOnline.jsx
@@ -164,12 +162,8 @@ website/
│ │ ├── lib/
│ │ │ ├── format.js
│ │ │ ├── heroLayout.js
│ │ │ ├── navOverrides.js
│ │ │ ├── settingsJson.js
│ │ │ ├── shardEvents.js
│ │ │ ├── themeVars.js
│ │ │ ├── useAsync.js
│ │ │ ├── useNavOverrides.js
│ │ │ ├── useShardFeatures.js
│ │ │ └── useShardFeed.js
│ │ ├── routes/
@@ -180,10 +174,8 @@ website/
│ │ │ │ │ ├── AdminCharacter.jsx
│ │ │ │ │ ├── AdminCharacters.jsx
│ │ │ │ │ ├── Appeals.jsx
│ │ │ │ │ ├── AppearanceAdmin.jsx
│ │ │ │ │ ├── AuthProvidersAdmin.jsx
│ │ │ │ │ ├── BotActivityAdmin.jsx
│ │ │ │ │ ├── BrandAssetsPanel.jsx
│ │ │ │ │ ├── Dashboard.jsx
│ │ │ │ │ ├── DiscordBotAdmin.jsx
│ │ │ │ │ ├── EmailDelivery.jsx
@@ -192,12 +184,10 @@ website/
│ │ │ │ │ ├── InvitesAdmin.jsx
│ │ │ │ │ ├── Moderation.jsx
│ │ │ │ │ ├── ModerationUser.jsx
│ │ │ │ │ ├── NavEditor.jsx
│ │ │ │ │ ├── PageBuilder.jsx
│ │ │ │ │ ├── PagesAdmin.jsx
│ │ │ │ │ ├── PostEditor.jsx
│ │ │ │ │ ├── PostsAdmin.jsx
│ │ │ │ │ ├── PublicNavTree.jsx
│ │ │ │ │ ├── SettingsAdmin.jsx
│ │ │ │ │ ├── ShardAdmin.jsx
│ │ │ │ │ ├── ShardOps.jsx
@@ -259,11 +249,8 @@ website/
│ │ ├── apiClient.test.js
│ │ ├── format.test.js
│ │ ├── heroLayout.test.js
│ │ ├── navOverrides.test.js
│ │ ├── regionBuckets.test.js
│ │ ├── settingsJson.test.js
│ │ ├── shardEvents.test.js
│ │ └── themeVars.test.js
│ │ └── shardEvents.test.js
│ ├── index.html
│ ├── package-lock.json
│ ├── package.json
@@ -319,7 +306,6 @@ website/
│ │ │ ├── brand.js
│ │ │ ├── csp.js
│ │ │ ├── notificationStreams.js
│ │ │ ├── themePresets.js
│ │ │ └── version.js
│ │ ├── middleware/
│ │ │ ├── botScore.js
@@ -508,12 +494,6 @@ website/
│ │ │ │ │ ├── shard.router.js
│ │ │ │ │ ├── site.router.js
│ │ │ │ │ └── wiki.router.js
│ │ │ │ ├── settings/
│ │ │ │ │ ├── index.js
│ │ │ │ │ ├── nav.controller.js
│ │ │ │ │ ├── nav.router.js
│ │ │ │ │ ├── theme.controller.js
│ │ │ │ │ └── theme.router.js
│ │ │ │ └── v1.router.js
│ │ │ ├── api.router.js
│ │ │ ├── cspReport.controller.js
@@ -523,26 +503,21 @@ website/
│ │ │ ├── auth.js
│ │ │ ├── botInternalClient.js
│ │ │ ├── botInternalKey.js
│ │ │ ├── brandAssets.js
│ │ │ ├── clilocParse.js
│ │ │ ├── clilocSource.js
│ │ │ ├── db.js
│ │ │ ├── htmlShell.js
│ │ │ ├── logger.js
│ │ │ ├── mailer.js
│ │ │ ├── navOverrides.js
│ │ │ ├── newsGump.js
│ │ │ ├── pushDispatch.js
│ │ │ ├── sanitizeHtml.js
│ │ │ ├── secretBox.js
│ │ │ ├── settingsJson.js
│ │ │ ├── shardBroadcast.js
│ │ │ ├── shardIngest.js
│ │ │ ├── shardSales.js
│ │ │ ├── shardVisibility.js
│ │ │ ├── spawnAtlasParse.js
│ │ │ ├── spawnAtlasSource.js
│ │ │ ├── themeResolve.js
│ │ │ ├── totp.js
│ │ │ ├── trustProxy.js
│ │ │ ├── uoLinkClient.js
@@ -567,13 +542,11 @@ website/
│ │ ├── authTrustedDevice.test.js
│ │ ├── botInternalKey.test.js
│ │ ├── botScore.test.js
│ │ ├── brandAssets.test.js
│ │ ├── clilocParse.test.js
│ │ ├── clilocSource.test.js
│ │ ├── csp.test.js
│ │ ├── emailConfig.model.test.js
│ │ ├── honeypot.test.js
│ │ ├── htmlShell.test.js
│ │ ├── inviteController.test.js
│ │ ├── invites.test.js
│ │ ├── loginProtection.test.js
@@ -584,7 +557,6 @@ website/
│ │ ├── mobileSsoBridge.test.js
│ │ ├── moderation.model.test.js
│ │ ├── moderation.test.js
│ │ ├── navOverrides.test.js
│ │ ├── newsGump.test.js
│ │ ├── notificationsRoutes.test.js
│ │ ├── pages.model.test.js
@@ -605,7 +577,6 @@ website/
│ │ ├── secretBox.test.js
│ │ ├── selfTrustedDevices.test.js
│ │ ├── session.test.js
│ │ ├── settingsTheming.test.js
│ │ ├── shardBroadcast.visibility.test.js
│ │ ├── shardControllerPublic.test.js
│ │ ├── shardIngest.champsPages.test.js
@@ -622,7 +593,6 @@ website/
│ │ ├── ssoCallback.test.js
│ │ ├── ssoState.test.js
│ │ ├── ssoTrustedDevice.test.js
│ │ ├── themeResolve.test.js
│ │ ├── totp.test.js
│ │ ├── trustedDevices.test.js
│ │ ├── trustProxy.test.js

View File

@@ -1,981 +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 |
| Theme delivery | **The server resolves the whole effective token set** and the client writes it as CSS custom properties. No `[data-theme]` blocks — see §6.2 |
| 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.~~ **Corrected in Phase 3 — this fix as written was a no-op.**
`getPublic().brand` never exposes `accentInt` (`publicBrand.test.js` asserts
it is `undefined`, deliberately: it is a Discord-only integer form), and the
server-side `brand.accentInt` has no consumer at all. Discord embeds are
colored by **`bot/src/brand.js`, in a separate process**, reading
`BRAND_ACCENT_COLOR` from env at boot — so there was nothing per-request to
recompute, and the drift the note describes was real but unfixable from the
server. What Phase 3 actually did: the bot now fetches
`GET /public/settings` → `brand.accent` (it already has a public-API client)
behind a 10-minute cached getter, keeping env as the fallback. See
"Phases 3–4 as landed" below.
- `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
> **Superseded in Phase 3.** The presets below are correct as *values* and were
> built as specified, but they do **not** live in `theme.css` as `[data-theme]`
> blocks. They live in `server/src/config/themePresets.js`, and the server
> resolves the effective token set into `getPublic().theme` for the client to
> write onto `<html>`. See "Phases 3–4 as landed" for why, and note two
> corrections the build made to the palettes: each preset carries the **full**
> color set (fifteen tokens, not the eight below), and `--shadow-card` is
> themed alongside the radii.
`:root` 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.
**`nav_public` may also be a wrapper** (Phase 10), because the public header is
the one nav an admin can restructure rather than only reorder:
```json
{
"items": { "/site/champs": { "order": 0, "section": "sec_a1b2" } },
"sections": [ { "id": "sec_a1b2", "label": "The World", "order": 4 } ],
"links": [ { "id": "lnk_c3d4", "label": "Player Guide",
"to": "/wiki/new-player-guide", "order": 1, "section": "sec_a1b2" } ]
}
```
- **A bare map is still read as the items map.** Every item key is a path
starting with `/`, so it can never collide with the literal key `items` — the
detection is unambiguous, and a nav with no sections still *stores* the bare
map, so this feature changed nothing for one that does not use it.
- `nav_admin` / `nav_player` keep the bare map; `sections` and `links` are
dropped for them, since neither layout can render an admin-created section.
- Top-level order is one number line shared by ungrouped entries **and
sections**; within a section, by its members. An admin-created entity with no
stored order appends after the coded ones rather than jumping to the front.
- **One level only.** No menu inside a menu.
- An `items[].section` or `links[].section` naming no declared section falls back
to the top level, mirroring the "group must name an existing title" rule.
## 7. Navigation: hard constraint
> **Amended in Phase 10.** This section originally said the override layer
> "cannot introduce a `to` that is not already in the corresponding hardcoded
> `NAV` array". That is still true of every **coded** entry, but the public
> header now also lets an admin add links of their own, so the constraint is
> restated below in the narrower form that survives. Nothing about the *gates*
> changed.
The override system can affect a **coded** entry's `label`, `order`, `hidden`,
and which container it sits in — `group` on the admin nav (an *existing* titled
section) or `section` on the public header (an admin-created dropdown).
It **cannot**:
- change a coded entry's `to`, or introduce a new one in its place;
- change or remove an entry's `roles` (admin nav) or `feature` (public nav) gate;
- un-hide an entry for a viewer whose role or feature check would otherwise fail.
**The public header may additionally carry admin-created `sections` and
admin-authored `links`** (§6.4, §7.2). This is a genuine widening and is worth
stating plainly:
- A **section** is a container with a label and a position. It has no `to` and is
never itself a link — it only opens — so it adds no reachable surface at all.
- A **link** is the one thing an admin may add to a nav, and the only place a path
is not required to already exist in code. It is restricted to a **same-origin
path**: no scheme, no protocol-relative `//host`, no whitespace or quotes. The
nav is not a place to send visitors to an origin the operator does not control.
- A link carries **no `roles` or `feature` of its own, and needs none**: the page
behind it enforces its own access, so a link to somewhere the viewer cannot
reach behaves exactly as typing that address would. Adding a link advertises a
route; it never grants one.
The property this rests on is structural rather than a check someone has to
remember: coded entries live in an `items` map whose keys **must** be routes the
base array declares, so that map can never introduce a route, while everything
that *can* name an arbitrary path lives in `links`, where the path rule is
applied on both the write and the read path.
The existing filters in
[`SiteHeader.jsx`](../../website/client/src/components/SiteHeader.jsx) and
[`AdminLayout.jsx`](../../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.
Three existing behaviors the merge must not disturb:
- **Empty dropdowns.** A section whose every entry is filtered out by a shard
feature must not render at all — a menu that opens onto nothing is worse than
no menu. `pruneNav` applies the gate inside a section and then drops one it
leaves empty.
- **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.
**As built (Phase 1).** Two shapes are handled by the one function — flat
(`SiteHeader`, `PlayerPortalLayout`) and grouped (`AdminLayout`) — detected by
whether every entry carries an `items` array. Three rules the doc left open,
settled by the implementation and locked by tests:
- **Ordering.** An item the admin never reordered keeps its index in the base
array as its sort key, so setting one `order` does not scramble the rest.
Explicit and implicit keys therefore share one number line and can collide;
ties break **explicit first** (an admin who said "0" means first, not
"wherever the untouched item at index 0 already sits"), and two explicit
equal orders keep code order via a stable sort. The editor writes an order for
every item in a list the way drag-and-drop does, so ties are the stale-row
case, not the normal one — they just have to resolve predictably.
- **`group`.** Accepted only when it names a title the base nav already
declares; anything else is dropped, so an item can never land under a header
that does not exist. Group *order* is not overridable — sections stay in code
order, only membership and within-group order move.
- **Field-by-field validation.** A bad `label` does not discard a good `order`
beside it, and `hidden` is honored only as the literal boolean `true`.
Everything unrecognized is ignored rather than rejected, so a hand-edited row
degrades to the code default instead of rendering a broken nav.
`hidden: false` cannot un-hide anything: hiding here is subtractive only, and
the role/feature filters still run afterward, unchanged.
## 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 presets, 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 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 — Palette-following literals + Parchment** ❌ **cancelled** | Was: promote the hue-carrying `rgba()` literals of §4.8 so they follow the palette, then the light-mode port. Not scheduled — see "Phase 9, cancelled" below |
| **10 — Public nav sections + added links** ✅ | Admin-created dropdown sections in the public header, coded entries organised into them, and admin-authored same-origin links. `NavDropdown.jsx`, `buildPublicNav`/`pruneNav`, the `nav_public` wrapper of §6.4, and the Public tab's own tree editor. **Amends §7** |
Phases 0–2 are one PR pair (website + docs), 3–4 a second, 5 a third, 6–8 a
fourth. **All four PR pairs target `edge`, not `main`** — the feature reaches
`main` as one `edge` → `main` merge once every phase is in, so no release ever
carries a half-wired theme engine. Phase 8 is the last one, so that merge is
what closes the feature.
### Phases 0–2 as landed
- **`/api/v1/settings` is a fifth router group**, not a route bolted onto an
existing one. §4.2 named the URL but not where it lives, and the domain split
leaves no group it fits: `/public` is anonymous, `/admin/settings` is
`adminOnly` while `AdminLayout` renders for editors and moderators, and
`/player` is data scoped to `req.user.id`. The group carries
`noindex, requireAuth` and no role gate. The route-manifest guard test that
asserts every `/admin/**` and `/player/**` route sits behind `requireAuth` now
covers `/settings/**` too.
- **Reset is `DELETE /api/v1/admin/settings/:key`** with the allowlist in
`settings.model.js` (`DELETABLE_KEYS`), which is what stops a stray request
from dropping `site_mode` or the uo-link config. It is admin-only and
idempotent, and a test asserts it never writes a row.
- **`parseJsonSetting` lives at `server/src/utils/settingsJson.js`.** Non-object
JSON (`4`, `"x"`, `null`, `[]`) is treated as absent alongside syntax errors,
and a validator rejection discards the whole object rather than half-applying
it. The client keeps `parseLayout`; a client-side counterpart arrives with its
first consumer in Phase 3.
- **Phase 2 was a 23-declaration promotion** — 14×`8px` → `--radius-input`,
4×`999px` → `--radius-pill`, 4×`10px` → `--radius-card`, 1×`12px` →
`--radius-panel` — matching the §4.7 census exactly. The `7px`/`6px` editor
chrome and the two `50%` circles stay literal. `--shadow-card` and
`--panel-grad` were **already** tokens and already derived, so the shadow half
of the phase was a no-op; the only two `box-shadow` declarations in
`theme.css` both already read `var(--shadow-card)`.
### Phases 3–4 as landed
Four things the design settled differently once it met the code.
**1. The server resolves the whole token set; there are no `[data-theme]`
blocks.** §6.2 put the presets in `theme.css` and had the client set a
`data-theme` attribute. That does not work as written: `SiteContext` writes
`--accent` as an **inline style on `<html>`** (`SiteContext.jsx:31`), and an
inline property beats any attribute-selector block. An admin who picked Fantasy
without also setting a custom accent would have had Fantasy's `#c9973f` painted
over by `brand.accent` from env — and §4.5's whole point is that
`getPublic().brand.accent` is what the phone app themes itself from, so the two
surfaces would have disagreed about the accent while both being "right".
The fix removes the conflict rather than sequencing around it. Presets live in
`server/src/config/themePresets.js`; `server/src/utils/themeResolve.js` layers
`:root` ← preset ← custom **per field** into a token map; `getPublic()` returns
it as `theme`; `client/src/lib/themeVars.js` writes it onto `<html>`. One
authority for the merge, `brand.accent` is by construction the accent the site
actually paints, and `theme.css`'s `:root` is untouched — an instance with no
row gets no `theme` block, the client writes nothing, and the page renders
byte-for-byte as today.
The client half's real logic is *removal*: inline properties are not cleared by
writing a smaller object over them, so `applyThemeTokens` tracks what it set
last time and `removeProperty`s whatever the new payload no longer mentions.
Without that, "Reset to defaults" would look broken until a reload.
**2. Presets carry the full fifteen-token palette, and theme `--shadow-card`.**
§6.2's blocks set eight colors. Applied literally, Fantasy's warm brown page
would have kept `--line: #2a3544` and `--blue: #13243c` — dark blue-grey borders
and a blue-grey active nav row — because those tokens are not in the list.
Every preset now sets `--panel-flat`, `--line`, `--line-soft`, `--head`,
`--muted`, `--dim` and `--blue` as well. The admin *form* still exposes only
§6.1's eight; the rest are supporting shades a preset gets right coherently but
that are not worth hand-picking. `--mode-live` / `--mode-maint` stay fixed
across every preset (green means live) and `--panel-grad` stays derived, both
locked by tests.
**3. The option catalog is served, not duplicated.**
`GET /api/v1/settings/theme/options` returns the presets (with their full token
maps, so a control can show what an unset field currently resolves to), the font
shortlist, the shadow depths, and the editable field names paired with the CSS
variable each drives. Duplicating those lists in client code would mean the form
could offer a font the server rejects, which surfaces as a save 400ing for no
visible reason. A test asserts every offered option validates.
Validation is deliberately asymmetric: **strict on write** (`PUT
/admin/settings` 400s and names the offending field) and **forgiving on read**
(a bad field is dropped, its neighbours keep applying). Strict-on-write gives
feedback; forgiving-on-read means a row hand-edited in the DB degrades to the
shipped default instead of rendering a broken site.
One addition to §5.1's twelve font options: **Georgia in the serif list.** The
shortlist gave the sans role a "today's default" option (Arial, byte-identical
to `--sans`) but left serif with no way back to `Georgia, "Times New Roman",
serif` short of resetting the whole theme. It pulls in no web family, so §5.2's
combined URL is unchanged.
**4. The Discord bot fetches the accent; §4.5's `accentInt` note was a no-op.**
See the correction in §4.5. `bot/src/brand.js` now reads
`GET /public/settings` → `brand.accent` through the public-API client it already
had, behind getters with a 10-minute TTL — so `brand.accentInt` stays a plain
property read at every existing call site, an embed never awaits a network call,
and any failure (site down, maintenance, malformed body) keeps the last known
good value with `BRAND_ACCENT_COLOR` as the floor.
**Found while smoke-testing: §4.8's rgba literals are not only a light-mode
problem.** The 28 dark-assuming `rgba()` literals were scoped to Phase 9 on the
reasoning that they break a *light* preset. Applying **Fantasy** on a live
instance shows they also carry a **hue**: `.btn-ghost`'s
`background: rgba(11, 22, 48, 0.45)` (essentially `--blue` at 45%) leaves the
portal's quick-link buttons reading blue on a warm brown page, and the hero
overlay stack in `heroLayout.js` is `rgba(11,15,20,…)` regardless of preset.
Nothing is broken or unreadable — it is a visible seam, not a bug — but Phase 9
should be re-scoped from "light-mode port" to "make the hue-carrying literals
follow the palette", which the dark presets need too. Not fixed here: it is the
23-declaration-style promotion Phase 2 was, and folding it into the phase that
introduced the presets would have hidden it inside an unrelated diff.
**Deferred to Phase 5, and done there:** the theme arrives with the
`/public/settings` fetch, so a themed instance painted the shipped palette for
one frame before repainting. Phase 5 had to rewrite `renderIndexHtml` into a
cached, invalidated shell anyway (§4.3), and injecting a `<style>` block with the
effective tokens there removed the flash for free rather than solving it twice.
**Also fixed in passing:** `settings/nav.controller.js` imported the logger
*factory* rather than calling it, so `log.error` was `undefined` and a DB fault
would have thrown a `TypeError` inside the catch — no response sent, request
left hanging — instead of returning a 500. Introduced in Phase 0.
### Phase 5 as landed
**The upload is one call, not two.** §8 said "upload endpoint on the existing
multer config", which reads as: reuse `POST /admin/uploads`, then `PUT` the
`brand_assets` row. Two problems with that. The generic upload is `staffOnly` —
editors can reach it — while the row it would write is `adminOnly`, and the
site's identity is not the editor tier's to change. And a run that uploaded and
then failed (or was abandoned) would leave a file in `/uploads` that nothing
references.
So: **`POST /api/v1/admin/settings/brand-asset/:slot`**, `adminOnly`, using the
shared `imageUpload.js` multer config and returning `{ url, brand_assets }`. It
read-modify-writes the row, so uploading a logo never clears a hero (§6.3). The
per-slot rules only ever *tighten* the shared allowlist, never widen it (§9):
| Slot | Types | Cap |
|---|---|---|
| `logo` | the shared image allowlist | 1 MB |
| `hero` | the shared image allowlist | 8 MB (the shared ceiling) |
| `favicon` | **PNG only** (§4.10) | 512 KB |
The cap is enforced after multer has written the file and the file is unlinked
before the response, rather than by a second multer instance with its own limits.
One upload config and one allowlist is the property worth keeping; a briefly
written file that is deleted before the request returns is not.
**There is no per-slot delete route.** Clearing one asset is a `PUT` of the
remaining ones, and clearing the last one is the existing reset-by-delete —
`{}` is never stored, because absence of the row is what selects the env
defaults (§2) and a stored empty object would be a second way to say the same
thing.
**`brand_assets` needed a validator of its own, which the design did not
anticipate.** These are the only settings values written straight into HTML as
URLs the browser then fetches — an `<img src>`, a `<link rel="icon">`, an
`og:image`. `utils/brandAssets.js` accepts a same-origin path under `/uploads/`,
`/brand/` or `/assets/` and nothing else: no scheme, no protocol-relative
`//host` (which looks like a path and loads off-origin), no `..`, no whitespace
or quotes. Same asymmetry as the theme — strict on write with the field named,
forgiving on read so one hand-edited slot does not cost the admin the other two.
**The shell cache carries a TTL as well as explicit invalidation.** §4.3 asked
for a module-level cache invalidated on write, and that is what the settings
controller does. But the cache is *per process*: in a scaled deployment the
worker that handled the write is the only one that learns of it, and every other
would serve the old favicon until the next restart. A 5-minute TTL makes the rest
converge on their own while keeping the steady state at one render per process
per five minutes — not one per page view. Concurrent first requests share a
single render, an invalidation that lands mid-render is not overwritten by the
in-flight result, and a failed settings read renders the env-only shell and
caches *that*, so an outage is not a failing query per page view.
**Theme flash: fixed here, with a handoff.** The shell now also carries the
resolved tokens as `<style id="theme-boot">:root{…}</style>`, injected last in
`<head>` so it follows the built stylesheet and wins the equal-specificity tie.
`SiteContext` removes that block once the `/public/settings` payload has arrived
and been applied — otherwise a later reset would remove the inline properties
only to reveal the stale block underneath. The removal is gated on a
**successful** fetch, not merely a finished one: a failed request leaves the app
with no theme at all, and dropping the block then would strip a themed instance
back to the shipped palette for no reason.
**The logo went into all six MoonDot surfaces, not three.** §8 named the three
persistent shells (site header, admin sidebar, portal sidebar); the admin login,
the player login/register card and the maintenance page carry the same mark and
an operator who uploads a logo means their instance, not three of its pages.
`components/BrandLogo.jsx` renders **nothing** when `brand.logo` is empty — which
is the shipped default — so every one of those surfaces is unchanged on an
untouched instance. On the three centered layouts the logo is stacked *above* the
moon rather than beside it, because turning that block into a flex row would have
changed its height on instances with no logo.
The footer's "powered by Runic Gateway" emblem is deliberately untouched (§4.11):
it is the project's badge, not the instance's.
**The hero chain needed no code.** §4.9's real order —
`hero_layout.background.image_url` → `brand_assets.hero` → `BRAND_HERO` →
`/assets/img/runic-emblem.png` — already holds, because Phase 3 resolved
`brand_assets` into `getPublic().brand.hero` and `SiteContext.heroImage` reads
that. What was missing was saying so: the hero row in the admin panel now states
that a hero-editor background wins over the uploaded one, so "I uploaded a hero
and the portal ignored it" does not become a bug report against a working system.
**Observed and left alone:** the shell's `<title>` and description still come
from `BRAND_NAME`/`BRAND_DESCRIPTION`, not from the admin-set `site_title` that
`getPublic().brand.name` prefers, so an instance that renamed itself through the
admin panel still has the env name in its tab and its link previews. Fixing it
would change the served shell for instances with no `brand_assets` row, which is
exactly what §9 says must not change in this phase. It wants its own change.
### Phases 6–8 as landed
The nav half, wired end to end: the public header, the admin sidebar and the
player portal all read their override row, and `/admin/navigation` writes them.
Five things the design did not settle.
**1. The server had no way to store a nav row, and would have stored garbage.**
§8 described phases 6–8 as client work, and for the *merge* that is right. But
`updateSettings` validates and stringifies `theme_visual` and `brand_assets` and
lets everything else through to `settingsDb.set` — so a `nav_public` object would
have been written as the string `"[object Object]"`, which `parseJsonSetting`
then reads as absent. The save would have returned 200 and done nothing, for
ever. `server/src/utils/navOverrides.js` mirrors `utils/brandAssets.js`:
`validateNavOverrides` is strict on write and names the offending key,
`resolveNavOverrides` is forgiving and drops fields that would do nothing.
**2. The server cannot check that a `to` exists, and should not try.** The three
base `NAV` arrays are client constants. Shipping a copy to the server would
create a second source of truth for navigation that drifts the first time a route
is added, and it would buy nothing: `applyNavOverrides` already drops an entry
whose `to` the base array does not declare, which is the right place for it — a
route deleted in code stops mattering immediately, with no migration. **The
server validates shape; the client owns membership.** So the write path accepts
any app-internal path as a key (absolute, no scheme, no `//host`, no whitespace)
and rejects everything else, and it rejects any field that is not one of the
four — a `roles` or `to` in the body is a 400, not something quietly stored.
**3. `hidden: false` is accepted and never stored.** The editor sends it while a
row is being edited, so rejecting it would be hostile; storing it would leave a
row that reads like an instruction to *force* something visible, which this layer
must never be able to express. It is dropped on the way in, and hiding stays
subtractive.
**4. The nav editor cannot be hidden, and that is enforced three times.** An
admin who hid `/admin/navigation` would lose the only screen that can un-hide it.
The row's eye toggle is disabled with a note saying why; `resolveNavOverrides`
drops `hidden` on that one `to` for `nav_admin`; and `AdminLayout` strips it
again before merging, which is what also covers a row edited straight in the
database. Typing the URL still works regardless — the guard is about not
stranding an admin who never learned it.
**5. Orders are written only when something actually moved.** §7.1 says the
editor writes an order for every item "the way drag-and-drop does", and it does —
but only for a nav whose sequence differs from the code's. An admin who renames
one item stores exactly one field, and a route added to `NAV` later still lands
where the code puts it. The comparison is against the base **restricted to the
rows that admin can see**, so a role- or feature-gated item missing from their
palette is not mistaken for a reorder. An override for such an item is carried
through their save untouched rather than quietly reset.
Two smaller notes. The section dropdown offers "(no section)" only to rows coded
into an untitled group (Dashboard, Account): for anything else it is a move an
override cannot express (§6.4 allows an existing titled section or nothing), so
offering it would silently do nothing. And `useNavOverrides` keeps one
module-level copy of the two authenticated rows, which is what lets a save in the
editor update the sidebar the admin is looking at without a reload — and stops
the second layout to mount from flashing the coded nav first.
### Phase 10 as landed
Asked for after phases 6–8 were built and before the `edge` → `main` cutover:
the public site should support dropdown sections with links organised inside
them. Scoped to the **public header only** — the admin sidebar keeps its four
coded sections and the player portal its three flat rows — and to **same-origin
links**, which is what makes §7's amendment a narrowing rather than an opening.
**The shape change was free because nothing had shipped.** `nav_public` grew a
`{items, sections, links}` wrapper. Had this landed after the cutover it would
have needed a migration or a version field; before it, a forgiving read of the
bare map is enough, and that read is kept anyway as insurance for a row written
during review.
**Sections are entries in the top-level order, which is why the Public tab has
its own editor.** The admin sidebar's groups are a fixed frame the code declares:
only membership moves. A public section is something the admin created and can
drag among the pills. That is a tree, not a list of groups, so
`PublicNavTree.jsx` renders it with a nested `SortableContext` per section, while
the other two tabs keep the phase-7 grouped editor. The shared `Row` was
generalised — its destination `<select>` takes a list of choices instead of
knowing about admin group titles.
**Moving between containers is still the dropdown, not a drag**, exactly as on
the Admin tab. Cross-container dragging is a lot of interaction surface for
something an admin does once, and keeping every drag a simple reorder is what
lets the nested contexts stay independent.
**Deleting a section does not delete what is inside it.** The entries move back
to the top level. It is the one destructive act this screen could commit — those
are coded pages and the admin's own links — so it is locked by a test.
**The dropdown opens on click, never hover, and the trigger is not a link.** A
hover menu is unusable on touch, and making the trigger navigate means tapping to
open takes you somewhere instead. A section is a container, not a destination.
`NavDropdown.jsx` carries the rest of the contract: Escape closes and returns
focus, an outside press closes, navigating closes, Arrow Up/Down walk the items,
and `aria-haspopup`/`aria-expanded` let it be announced as a menu.
**A bug the palette filter had, found by the test for it:** `buildNavOverrides`
judged "does this route still exist?" against the *palette* — the base array
already filtered to what the editing admin can see. For the admin nav that is
harmless (an admin sees every row), but on the public header a shard-feature-gated
row is filtered out, so the guard meant to carry its override through could never
fire, and their save would have quietly reset it. Membership is now judged against
the **full** coded nav while the rows still come from the palette: they are two
different questions.
### Phase 9, cancelled
The §4.8 `rgba()` literal promotion and the Parchment light-mode port are **not
scheduled**. The finding that motivated them stands and is worth keeping: those
literals carry a *hue*, not merely a light/dark assumption — `.btn-ghost` is
`rgba(11,22,48,0.45)`, so the portal quick-links read blue on Fantasy's warm
page. It is a real rough edge in the three dark presets, not only a blocker for a
hypothetical light one. It is simply not worth the contrast pass across every
component right now. Anyone picking it up should start from the census in §4.8
and the live observation in "Phases 3–4 as landed".
### 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.
- A dropdown section whose every entry is hidden by shard visibility **does not
render at all**, rather than opening onto an empty menu.
- An added link cannot leave the origin: a `to` carrying a scheme, a
protocol-relative `//host`, whitespace or quotes is refused on write and dropped
on read. An added link never grants access — the page behind it still gates
itself.
- Deleting a dropdown section returns its entries to the top level; it never
removes a coded page or an admin's own link.
- Deleting a theme/asset/nav row returns that surface to env/code defaults, not to
a stored copy of the defaults.

View File

@@ -249,14 +249,6 @@
"method": "PUT",
"path": "/api/v1/admin/settings"
},
{
"method": "DELETE",
"path": "/api/v1/admin/settings/:key"
},
{
"method": "POST",
"path": "/api/v1/admin/settings/brand-asset/:slot"
},
{
"method": "POST",
"path": "/api/v1/admin/shard/account"
@@ -301,18 +293,6 @@
"method": "GET",
"path": "/api/v1/admin/shard/char/:serial"
},
{
"method": "GET",
"path": "/api/v1/admin/shard/clilocs"
},
{
"method": "POST",
"path": "/api/v1/admin/shard/clilocs/import"
},
{
"method": "PUT",
"path": "/api/v1/admin/shard/clilocs/path"
},
{
"method": "GET",
"path": "/api/v1/admin/shard/houses"
@@ -837,30 +817,10 @@
"method": "GET",
"path": "/api/v1/public/shard/idoc"
},
{
"method": "GET",
"path": "/api/v1/public/shard/market"
},
{
"method": "GET",
"path": "/api/v1/public/shard/market/meta"
},
{
"method": "GET",
"path": "/api/v1/public/shard/market/vendors/:serial"
},
{
"method": "GET",
"path": "/api/v1/public/shard/online"
},
{
"method": "GET",
"path": "/api/v1/public/shard/points"
},
{
"method": "GET",
"path": "/api/v1/public/shard/points/:system"
},
{
"method": "GET",
"path": "/api/v1/public/shard/presence"
@@ -900,14 +860,6 @@
{
"method": "GET",
"path": "/api/v1/public/wiki/tags"
},
{
"method": "GET",
"path": "/api/v1/settings/nav"
},
{
"method": "GET",
"path": "/api/v1/settings/theme/options"
}
],
"internal": [