88 Commits

Author SHA1 Message Date
e7dea29885 Merge pull request 'docs(android): build contract for admin theming and navigation parity (M12)' (#111) from docs/android-theming-nav-plan into main
Reviewed-on: #111
2026-08-08 06:45:16 +00:00
b150e354a8 Merge pull request 'docs(tree): sync website/PROJECT_TREE.md' (#110) from chore/sync-website-tree into main
Reviewed-on: #110
2026-08-08 06:45:00 +00:00
e86b04567f docs(android): build contract for admin theming & navigation parity (M12)
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.

Adds docs/android/THEMING_AND_NAV.md as the design of record for M12, and the
PLAN.md §9 entry that anchors it. Plan only: no app code, no backend work.
Everything consumed is already live on website/main.

The points that shaped it:

- The app's ui/theme/Color.kt palette is already, value for value, the
  runic-gateway preset -- M5 was drawn from the same theme.css the preset was
  later extracted from. So "an untouched instance is unchanged" carries over as
  a testable ColorScheme equality assertion, not an approximation.
- Radii apply as a ratio against that baseline, not as literal dp. The app's
  Shapes came from the M5 mockup and genuinely differ (medium 12dp vs
  --radius-card 10px); a literal mapping would restyle the untouched app the
  day this ships, and copying the app's scale into the server would be a second
  source of truth.
- Fonts are bundled, not downloadable: the Play Store font provider makes a
  de-Googled device fall back silently. Seven families join the bundled Cinzel.
- Nav overrides are keyed by website paths, so the app needs a path -> route
  table -- the one new cross-repo coupling here. An override for a path the app
  does not surface in its menu is ignored: a nav override may never introduce
  navigation.
- 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.
- Read the resolved theme/brand fields, never the raw theme_visual/brand_assets
  rows that ride along in the same payload -- re-deriving a palette from them
  would be a second resolveThemeTokens in Kotlin, guaranteed to drift.

Nine phases into a fresh edge in both repos, reaching main as one edge -> main
merge, the same shape the website side used. Phase 0 must change nothing on
screen. Phase 7 (the authenticated navs) is marked optional: nav_player reaches
two app rows and nav_admin two, which is a thin return for a new authenticated
fetch and its cache teardown.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TgfKv5cz5pbY3dPeofSE5a
2026-08-08 01:39:52 -05:00
runic-docs-bot
3aed6bca17 docs(tree): sync website/PROJECT_TREE.md from RunicGateway/website@265042e [skip ci] 2026-08-08 06:19:50 +00:00
518f1e0449 Merge pull request 'docs(website): theming &amp; navigation, complete (edge → main)' (#109) from edge into main
Reviewed-on: #109
2026-08-08 06:09:06 +00:00
2e955f1e9c Merge pull request 'docs(website): phase 10 — public nav sections and added links, and the §7 amendment' (#108) from docs/theming-nav-phase-10 into edge
Reviewed-on: #108
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-08 06:00:18 +00:00
2784cad6e4 docs(website): phase 10 — public nav sections and added links, and the §7 amendment
Records the capability asked for before the edge -> main cutover: dropdown
sections in the public header, with the coded entries organised into them and
admin-authored links alongside.

§7 is amended rather than quietly contradicted. It said the override layer
"cannot introduce a `to` that is not already in the hardcoded NAV array"; that
remains true of every CODED entry, and the restated constraint spells out what an
added link may be — a same-origin path, carrying no gate of its own, advertising
a route rather than granting one — plus why the property is structural: coded
entries live in a map keyed by routes the base array declares, and everything
that can name an arbitrary path lives in `links`, where the rule is applied.

§6.4 gains the { items, sections, links } wrapper, including the two properties
worth knowing: a bare map still reads as the items map, and a nav with no
sections still stores one.

§9 gains three acceptance criteria — the empty dropdown does not render, an added
link cannot leave the origin, and deleting a section returns its entries to the
top level rather than removing them.

"Phase 10 as landed" records why the Public tab needed its own tree editor, why
moving between containers stayed a dropdown rather than a cross-container drag,
why the menu opens on click and its trigger is not a link, and the palette-vs-full-nav
bug this surfaced in the phase 6-8 save path.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 00:42:39 -05:00
d7dd0e5078 Merge pull request 'docs(website): theming phases 6-8 as built, and phase 9 cancelled' (#107) from docs/theming-nav-phase-6-8 into edge
Reviewed-on: #107
2026-08-08 05:11:45 +00:00
5c24ff1378 docs(website): theming phases 6-8 as built, and phase 9 cancelled
Marks the nav wiring and the builder UI landed, and records the five places the
build differed from the design:

- The server had no way to store a nav row. The design scoped 6-8 as client
  work, but updateSettings would have written a nav object as "[object Object]"
  — a save that 200s and does nothing, for ever.
- The server deliberately cannot check that a `to` exists: the base NAV arrays
  are client constants, and a server-side copy would be a second source of truth
  for navigation. Shape is the server's question, membership the client's.
- `hidden: false` is accepted and never stored, so hiding stays subtractive.
- The nav editor cannot be hidden, enforced in three places.
- Orders are written only when something actually moved, compared against the
  base restricted to the rows the editing admin can see.

Phase 9 (hue-carrying rgba literals + Parchment) is cancelled rather than
deferred. The finding that motivated it is kept as the record: those literals
carry a hue, so they are a rough edge in the three dark presets and not only a
blocker for a hypothetical light one.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 00:02:46 -05:00
2a8b9d5748 Merge pull request 'docs(website): theming phase 5 as built — brand assets and the cached HTML shell' (#106) from docs/theming-nav-phase-5 into edge
Reviewed-on: #106
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-08 02:05:03 +00:00
97cb8be2d5 docs(website): theming phase 5 as built — brand assets and the cached shell
Records Phase 5 of THEMING_AND_NAV.md as landed and documents the new route
and the shell lifecycle in BACKEND_DESIGN.md.

Where the build differed from the design: the upload is one admin-only call
that writes the settings row too (rather than the generic staff upload plus a
PUT, which would leave unreferenced files and let editors change the site's
identity); brand_assets needed a validator of its own because these are the
only settings values written straight into HTML as URLs; the shell cache
carries a TTL as well as explicit invalidation because it is per process; and
the logo went into all six MoonDot surfaces rather than three.

Also notes what was deliberately left alone: the shell's title and description
still come from BRAND_NAME rather than the admin-set site_title, and fixing
that would change the served shell for instances with no brand_assets row —
which is exactly what the phase's acceptance criterion forbids.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 20:10:16 -05:00
ce6f5b8788 Merge pull request 'docs(website): theming & nav phases 3-4 as built' (#105) from docs/theming-nav-phase-3-4 into edge
Reviewed-on: #105
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-08 00:21:41 +00:00
e669243aca docs(website): theming phases 3-4 as built
Records where the build diverged from the design and why.

- The presets do not live in theme.css as [data-theme] blocks. Section 6.2 is
  marked superseded and a "Phases 3-4 as landed" section explains the inline
  --accent precedence problem that forced server-side resolution.
- Section 4.5's accentInt fix is struck through: getPublic() never exposed
  accentInt, and Discord embeds are colored by a separate process reading env,
  so there was nothing per-request to recompute. Replaced with what was
  actually done -- the bot fetching the effective accent.
- Phase 9 re-scoped. Applying Fantasy on a live instance showed the section 4.8
  rgba literals carry a hue, not just a light/dark assumption, so the dark
  presets need that promotion too.
- BACKEND_DESIGN.md: the new /settings/theme/options route, the `theme` block
  on /public/settings, effective values in the brand block, theme_visual
  validation on PUT /admin/settings, route count 225 -> 226.
- api-route-inventory.json regenerated from the manifest.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 19:16:47 -05:00
09e6ffd67c Merge pull request 'docs(website): theming & nav phases 0-2 as built' (#104) from docs/theming-nav-phase-0-2 into edge
Reviewed-on: #104
2026-08-07 23:25:06 +00:00
252733644b docs(website): theming & nav phases 0-2 as built
Matches RunicGateway/website's phases 0-2 of THEMING_AND_NAV.md.

BACKEND_DESIGN.md:
- The new /settings router group and its one route, plus why it is a fifth
  group rather than a route on an existing one.
- DELETE /admin/settings/:key in the admin route table, with the allowlist and
  why reset deletes instead of writing.
- The five unseeded theming/nav keys under the settings schema: absence of the
  row is the "use the default" state, values are TEXT so consumers parse, and
  malformed reads as absent.
- Route count 215 -> 225.

THEMING_AND_NAV.md:
- Phases 0-2 marked landed, with an "as landed" section recording the three
  things the design left open: where /settings/nav lives, where
  parseJsonSetting lives, and the exact 23-declaration radius promotion.
- The nav merge util's ordering rules, settled by the implementation: an
  untouched item keeps its index as its sort key, an explicit order wins a tie
  against a coincidental index, equal explicit orders keep code order, and
  `group` is honored only when it names an existing section.
- All four PR pairs target `edge`; the feature reaches `main` as one merge.

api-route-inventory.json: resynced from server/routes.manifest.json. Picks up
the two new routes plus eight that were already missing from the mirror since
the Protocol 3.0 cutover (shard clilocs, market, points).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 18:15:50 -05:00
6398285a13 Merge pull request 'docs(website): add theming & nav build contract' (#103) from docs/theming-and-nav-plan into main
Reviewed-on: #103
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-07 22:07:47 +00:00
d33064e8d7 docs(website): add theming & nav build contract
Corrects the design doc against the current codebase and locks the open
decisions, in the same shape as HERO_EDITOR.md (locked decisions ->
corrections to reality -> phased build).

Blocking gaps found in the design doc:
  - no delete path exists for a settings row, which every "reset to
    defaults" in the feature depends on
  - editors/moderators/players have no endpoint to read their own nav
    overrides (GET /admin/settings is admin-only)
  - renderIndexHtml runs once at boot, not per request
  - settings values are JSON strings, not objects
  - getPublic().brand is a cross-repo contract the Android app and
    Discord embeds theme from; new keys would silently bypass it

Locked: effective values resolved server-side into getPublic().brand;
radius + shadow tokens only (spacing/border cut); radius tokens seeded at
today's real values so the promotion is a no-op; three dark presets in v1
with Parchment deferred; 12-option font shortlist across 8 web families
in one request; PNG-only favicons.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 17:04:54 -05:00
6d25279b32 Merge pull request 'docs(installer): lead with the installer now that it is released' (#102) from docs/installer-first-setup into main
Reviewed-on: #102
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-07 21:34:25 +00:00
5197c2c281 docs(installer): lead with the installer now that it is released
v0.1.0 shipped on 2026-08-07, so every doc that said "not released yet"
was wrong the moment the cutover merged.

- INSTALL.md: replace the pre-release status banner. The installer is the
  path the guide leads with; Appendix A is reframed as supported-not-
  deprecated, for hosts that cannot run the binary, operators who want to
  place files themselves, and development from a working tree.
- PLAN.md: status is Shipped, both cutover gates recorded as met (incl.
  the Windows 1053 handshake bug the real SCM run found), Phase 5 table
  and 5.4 closed out.
- README.md: point anyone setting up a shard at INSTALL.md first.
- link/link-README.md: mark the pre-split snapshot as historical, so its
  deploy.ps1 instructions stop reading as the setup path.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 16:05:39 -05:00
c73db117f5 Merge pull request 'docs(tree): sync installer/PROJECT_TREE.md' (#101) from chore/sync-installer-tree into main
Reviewed-on: #101
2026-08-07 20:29:37 +00:00
runic-docs-bot
22cf093f97 docs(tree): sync installer/PROJECT_TREE.md from RunicGateway/installer@1173a10 [skip ci] 2026-08-07 20:28:25 +00:00
4a35e86bc8 Merge pull request 'docs(installer): bundles publish to a branch, releases only tag' (#97) from docs/bundles-branch into main
Reviewed-on: #97
2026-08-07 19:15:33 +00:00
9889eefb0f Merge pull request 'docs(tree): sync installer/PROJECT_TREE.md' (#100) from chore/sync-installer-tree into main
Reviewed-on: #100
2026-08-07 19:15:06 +00:00
bc97e5d221 Merge pull request 'docs(tree): sync link/PROJECT_TREE.md' (#99) from chore/sync-link-tree into main
Reviewed-on: #99
2026-08-07 19:12:55 +00:00
runic-docs-bot
5c9aa2891c docs(tree): sync installer/PROJECT_TREE.md from RunicGateway/installer@09eafe2 [skip ci] 2026-08-07 19:12:12 +00:00
runic-docs-bot
c54dcb47f5 docs(tree): sync link/PROJECT_TREE.md from RunicGateway/link@67d7800 [skip ci] 2026-08-07 18:53:32 +00:00
18dfceae73 Merge pull request 'docs(installer): correct the Windows service decision, and the 1053 advice' (#98) from docs/windows-service-1053 into main
Reviewed-on: #98
2026-08-07 18:52:35 +00:00
7c1a88febb docs(installer): correct the Windows service decision, and the 1053 advice
PLAN.md §8 recorded that `sc create` against the plain console sidecar worked
and needed no change to `link`. The first Windows install disproved it: 1053,
"a timeout was reached (30000 milliseconds) while waiting for the service to
connect", with SERVICE_EXIT_CODE 0.

The premise was a false symmetry with systemd. systemd supervises any
foreground process; the Windows SCM supervises only one that calls
StartServiceCtrlDispatcher within ~30 seconds. Record the reversal and what it
costs: link gains a Windows service entry point, kept at the edges so the whole
sidecar stays shared and Cargo builds neither Windows crate for Linux.

INSTALL.md:
- Troubleshooting gains a 1053 row naming the real cause (a sidecar older than
  v1.2.0) and the two tell-tales that distinguish it from a crash: exit code 0,
  and a foreground run of the same binary working fine.
- The existing "stops immediately" row said the same wrong thing; it now covers
  the genuine-crash case only, and points at the log file and journalctl.
- §3 and Appendix A4 document the service log, and A4 states the version floor.
- Fixes a literal 0x08 byte in the backups path row, which rendered as
  `%ProgramData%\RunicGatewayackups\` — the backslash had been eaten.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 13:37:29 -05:00
ddcfb5de29 docs(installer): bundles publish to a branch, releases only tag
Two corrections to §7.1, both forced by the first compose run that ever
had a bundle to write (org lead, 2026-08-05).

The section said bundles are committed to `main` and that this "needs no
new branch-protection exception: release.yml's version-bump commit
already requires the CI user to be able to push to main". Both halves
were wrong. `main` is protected and declines the push, and release.yml
had never pushed anything: its bump step has never executed in any repo
carrying it, because an empty template expression written literally in
one of its comments makes the runner fail to build the step and skip it
without failing the job. The tags exist because Gitea's release API
creates one when it publishes. The assumption that a working push path
already existed had never been tested by anything.

Bundles now publish to a `bundles` branch at its root, which keeps every
property the original choice was for -- reviewable diff, git history of
the compat matrix, plain anonymous raw URLs, no credentials on the shard
host -- and needs no exception. The release workflows are tag-only for
the same reason, as servuo-plugins has always been: the version is still
written into Cargo.toml before building so a released binary
self-reports correctly, but is not committed back.

Also updated: the raw URLs in INSTALL.md Appendix A1 and in Phase 0.3's
as-built note.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 17:22:16 -05:00
00d476c00b Merge pull request 'docs(installer): Phase 5 — scope, the §5.3 correction, and the operator guide' (#96) from docs/installer-phase5-scope into main
Reviewed-on: #96
2026-08-05 17:52:20 +00:00
ad7defd471 docs(installer): document aarch64 and the upgrade backup in INSTALL.md
PLAN.md §5.4. The operator guide is the specification of the run, so
these are part of building the phase rather than a write-up after it.

- The download list gains runicgateway-installer-linux-aarch64, with
  `uname -m` as the way to tell, and says plainly that there is no macOS
  and no Windows-on-arm build: the shard dials the sidecar out on
  loopback, so the two share a host, and no ServUO host is either.
- Appendix A3 names the arm64 sidecar asset for the by-hand path, and
  points at the bundle from A1 for the version rather than the one
  written in the example.
- §7's `update` says what a backup is, when one is taken and when one is
  not, and that restoring is the operator's to do -- the guide already
  promised their Bridge.cfg edits survive, and this is the same promise
  for the .cs file they edited that gets overwritten by design.
- --no-backup joins the flags table; --purge's row and the uninstall
  table now name backups alongside the config, the database and the
  cached patch set.
- The paths tables and doctor's sample output gain the backups
  directory and its row.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 05:53:11 -05:00
e18eec8957 docs(installer): correct §5.3's trigger and record what building §5.2 found
Two things this section got wrong, both found by implementing it.

§5.3 said a backup is taken for an update and for an install over an
existing record, because "a first install overwrites nothing". That is
not true of a tree deployed by hand per INSTALL.md Appendix A2 -- the
path this project recommends while the binary is unreleased. There the
first install finds .cs files that differ, plans them as Change, and
overwrites them with no prior record anywhere to notice. The rule is
now the direct one: back up whenever the run is about to overwrite
something. A genuine first install onto a clean tree still writes
nothing, because there is nothing to copy.

Also records that sidecar.toml joins a backup rather than causing one
(nothing rewrites it, so triggering on it would leave a dated directory
after every no-op update), and that the directory is created lazily
with the manifest written last, so an interrupted run can neither be
mistaken for a backup nor evict a good one.

§5.2 gains what its cross-builds turned up: neither crate builds with
the arm64 compiler alone. gcc-aarch64-linux-gnu only recommends
libc6-dev-arm64-cross while both release workflows install with
--no-install-recommends, so the C in each crate -- bundled SQLite under
sqlx, ring under ureq's rustls -- fails on a missing libc header while
every Rust dependency compiles fine.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 05:49:09 -05:00
38f83ad2e0 docs(installer): settle Phase 5's scope
Phase 5 was sketched as four items; two of them are dropped rather than
deferred, because what stops them is an ownership conflict that does not
improve with time (org lead, 2026-08-05).

- No .deb and no MSI (5.1). A .deb under link's release would own the
  binary, the systemd unit and the service user -- the three things
  service.rs writes, hardens and removes and install.json records, so
  uninstall would leave a dpkg-installed-but-broken package and an
  apt upgrade would make doctor report drift nobody caused. The
  binary-only variant buys apt-managed upgrades of one file, which
  update already does from a protocol-checked bundle. An MSI
  contradicts "the installer does not install itself" and adds a second
  uninstall path beside the verb that owns install.json, the cached
  patch set and the ServUO-tree report.
- Linux aarch64 for both components (5.2), in the order the bundle CI
  forces: it hard-fails on an unrecognized link asset name and asserts
  the platform keys present, so the name is taught on main first, link
  publishes, the key becomes required, and only then does the crate on
  edge learn it. bundle.yml is never edited on edge, so the cutover
  merge has nothing to conflict over.
- Backup before overwrite (5.3), scoped by what cannot be fetched
  again: not the binary or the overlay files, and not the database
  (store.rs is CREATE TABLE IF NOT EXISTS over shard state the sweeps
  repopulate -- a cache with a schema), but an operator's edits to a
  deployed .cs file, which Phase 1 overwrites by design, and
  sidecar.toml, whose token the website already holds.
- The docs a first release invalidates (5.4), including the repo README
  still announcing Phase 1 four phases later.

Also records that the Windows SCM smoke was attempted on 2026-08-05 and
stopped at its first check on an unelevated shell, so that half remains
entirely unexecuted.

Co-Authored-By: Claude <noreply@anthropic.com>
(cherry picked from commit 8818c06f1d)
2026-08-05 05:20:26 -05:00
3eb9fa8653 Merge pull request 'docs(installer): put Phase 5 before the cutover, and flag Windows SCM as untested' (#95) from docs/installer-polish-before-cutover into main
Reviewed-on: #95
2026-08-05 10:12:17 +00:00
3a6b9196fa docs(installer): put Phase 5 before the cutover, and flag Windows SCM as untested
Two decisions from the org lead, recorded in the design of record.

Phase 5 (packaging polish) now runs BEFORE the edge -> main cutover
rather than after it. The original order assumed the cutover would cut a
v1 and packaging would follow as a v1.x, but this phase changes the
release layout itself: shipping first would mean a first release that is
immediately superseded, and operators who downloaded a bare binary being
told to re-download a package. Deferring costs nothing — nothing is
published from `edge`, and INSTALL.md's Appendix A is the supported path
meanwhile.

The cutover therefore has two entry criteria, stated in the status
header and at Phase 5: packaging polish, and the Windows SCM half being
verified on a real host. The second is called out explicitly because
`sc create`, the virtual service account, the failure actions and the
token-file ACL have still never been executed anywhere — and running the
systemd half for real is precisely what turned up a bug no unit test
had. Nothing should be released while the only untested code is the half
that registers a service.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 05:00:45 -05:00
c47e0fb303 Merge pull request 'docs(installer): record the first real systemd verification' (#94) from docs/installer-systemd-verified into main
Reviewed-on: #94
2026-08-05 09:30:29 +00:00
33fadbd254 docs(installer): record the first real systemd verification
Phase 2 shipped service registration that had never been executed: a
relocated test run deliberately skips it, `sc create` needs elevation,
and systemd needs a Linux host. It has now been run for real on a
privileged Debian 12 container with systemd as PID 1 — unit written and
enabled, service up as the unprivileged runicgateway user, sidecar.toml
600 and owned by it, database under /var/lib (so the UOLINK_DB_PATH pin
works), /health answering protocol 3, and uninstall taking the service,
unit, binary and account away while leaving the config, the database and
the whole ServUO tree alone.

That surfaced one bug only a real service host could show — user_created
was recorded per-run rather than as state, so an identical re-run
rewrote install.json and uninstall silently left behind the account the
installer had created (installer#8). Recorded here with the reason it is
invisible on Windows.

Also notes what is still unverified: the Windows SCM half, which needs an
elevated shell this machine's automation does not have.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 04:02:16 -05:00
1dc6084bf7 Merge pull request 'docs(installer): record Phase 4 — doctor, update and uninstall as built' (#93) from docs/installer-phase4 into main
Reviewed-on: #93
2026-08-05 08:49:12 +00:00
ecef87f120 docs(installer): record Phase 4 — doctor, update and uninstall as built
The installer crate now implements the whole command surface INSTALL.md
published before the binary existed, so this records what Phase 4 turned
out to be and corrects two places where the plan and the guide had drifted
apart.

PLAN.md
  - Status header: Phases 1–4 are on `edge`; the edge → main cutover now
    cuts a binary that does everything INSTALL.md describes, with Phase 5
    being packaging polish rather than capability.
  - A Phase 4 "as built" section: why `update` is the install pipeline in
    a different mode rather than a second implementation, why it neither
    reprints the token nor stays quiet about a protocol change, the tier's
    scope under `update` (re-resolve what was applied, without re-asking;
    name what is new), how `doctor` asks the binary the way the service
    does, the exit-code rule and why a stopped shard is a ⚠ while a
    running one that has not dialed in is a ✗.
  - §5's uninstall table: the cached patch set and patches/originals/ move
    from "removed" to "kept". The report that command prints tells the
    operator to diff against those originals — deleting them made the
    advice impossible to follow within one command's output. `--purge`
    removes them.

INSTALL.md
  - §2: exit codes stated (`doctor` and `uninstall` use 1 for a completed
    run that found something wrong), `--patches` now applies to `update`,
    `--yes` means yes on `uninstall`, `--purge` covers the patch cache.
  - §7 doctor: the real row set, what ✓/⚠/✗ mean, that it writes nothing
    and is safe to run with the shard up.
  - §7 update: it updates the tree install.json names, needs the shard
    stopped, does not reprint the token, calls out a protocol change, and
    what it does and does not do with the patch tier.
  - §7 uninstall: what survives, that edited files are flagged in the
    listing, the confirmation's default, and where the report file lands.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 03:00:45 -05:00
706b450828 Merge pull request 'docs(installer): record Phase 3 — the patch tier as built' (#92) from docs/installer-phase3 into main
Reviewed-on: #92
2026-08-05 01:07:46 +00:00
42e6f3a0cb docs(installer): record Phase 3 — the patch tier as built
PLAN.md gains a "Phase 3 as built" section covering the decisions the plan had
left open, and §2.2, §2.2.1 and §7.0 are brought in line with what shipped:

* The engine is fully native. §2.2.1 wrote rung 1 as "apply verbatim with
  git apply", but §1 chose the release tarball so there would be no git on the
  shard host, and rung 2 needs a native applier anyway. Rung 1 keeps its
  stronger verdict and shares rung 2's write path. On the real files this is
  not academic — the shipped patches are CRLF and two of their three targets
  are LF, so git apply refuses patches the installer places correctly.

* §7.0 documents `patch_tier` in the overlay manifest. Which patches form one
  unit, which companion follows which, whether a core rebuild is needed and
  what declining costs are not derivable from a diff, so the release declares
  them and adding a patch regenerates metadata rather than an installer.

* §2.2 gains the pre-image cache and the widened patch cache, and §2.2.1 gains
  the second, per-feature level of the all-or-nothing rule.

INSTALL.md's illustrated tier output is replaced with the real thing, the
status banner now says `install` is complete, §3's path tables list
patches/originals/, and Appendix A2 names the line-ending trap that makes
git apply refuse a patch whose region is visibly untouched.

Refs: RunicGateway/installer#6, RunicGateway/servuo-plugins#10

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 19:57:42 -05:00
5ae53d287f Merge pull request 'docs(installer): record Phase 2 as built — sidecar install and service' (#91) from docs/installer-phase2 into main
Reviewed-on: #91
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-04 20:44:59 +00:00
3f12e5f49c docs(installer): record Phase 2 as built — sidecar install and service
Phases 1 and 2 now live on the installer repo's `edge` branch, so PLAN.md's
status, the config-path section, and the operator guide all move with them.

PLAN.md
- Status: phases 1 and 2 built. The `edge -> main` cutover now follows Phase 3
  rather than Phase 2, because INSTALL.md §4 describes the patch tier as part of
  the run and a release that answers "not implemented" to all of it is the same
  half-capable binary that kept Phase 1 off `main`.
- §2.3: the service definition always pins the config path, but only Linux pins
  the database. On Windows config and data share a directory, so the sidecar's
  own anchoring rule already lands it correctly — and `sc.exe` offers no
  per-service environment, only a machine-wide one that every process inherits
  and that outlives an uninstall.
- New "Phase 2 as built" section: the virtual service account, the config
  lockdown and why its two halves straddle registration, `--verify` running no
  part of the sidecar half, the protocol check against the installed binary,
  `RUNICGATEWAY_STATE_DIR` relocating the binary and suppressing service
  registration, degrading to a printed recipe with no root/LocalSystem fallback,
  and the token never entering install.json.
- §8 question 1 (Windows service mechanism) resolved: `sc create`, as
  recommended — plus the service identity the recommendation did not anticipate.

INSTALL.md
- Status banner: what is built, and that the patch tier is the remaining gap.
- §2: the illustrated run matches the sidecar block the binary actually prints.
- §3: a table of how each platform pins config and database, the dedicated
  service account on both, and the fact that sidecar.toml's permissions are
  restricted because it holds the auth token.
- Appendix A4: the Windows recipe now matches what the installer does —
  `--config` in binPath (single-quoted so PowerShell keeps the inner quotes),
  `obj=` for the virtual account, the icacls lockdown before and grants after,
  and no machine-wide environment variables.
- Troubleshooting: a row for a run that could not register a service, and one
  for a service that starts and immediately stops.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 15:40:26 -05:00
ce02abbc11 Merge pull request 'docs(installer): record Phase 1 as built' (#90) from feat/installer-phase-1 into main
Reviewed-on: #90
2026-08-04 20:00:56 +00:00
4bdc764742 docs(installer): record Phase 1 as built
PLAN.md moves from "Phase 0 complete, no code exists" to "Phase 1 built, on
edge", and the Phase 1 section gains an "As built" block in the same shape as
the Phase 0 entries — covering the decisions that were not already settled by
the design: why the crate lands on `edge` instead of `main`, why the library
target is not named after the binary (Windows UAC installer detection makes
`cargo test` unrunnable under that name), the dependency choices that follow
from the MinGW cross-build, path-based rather than name-based shard-running
detection, reading ServUO's version from Server/AssemblyInfo.cs, and the two
rules the smoke test corrected — install.json recording a state rather than the
run's verb, and the Bridge.cfg keep comparing against the last hash deployed
rather than the last hash seen.

INSTALL.md gains the same status note and one troubleshooting row: Windows
elevates the binary on launch because its file name contains "install", which
is expected and needs no action beyond running from an elevated shell.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 14:58:46 -05:00
a98fceb4bb Merge pull request 'docs(installer): review the patched region, not the whole-file hash' (#89) from docs/installer-patch-region-review into main
Reviewed-on: #89
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-04 19:19:14 +00:00
5a6cb58a33 docs(installer): review the patched region, not the whole-file hash
The patch tier refused on a whole-file hash mismatch, which is the wrong
question: the three patches touch three small regions of three large files, so
an operator who edited Logging.cs somewhere else entirely was handed a manual
patch job they did not need. Hand-modified shards are the norm, so that refusal
covered most of the audience.

Replace the single hash test with a four-rung ladder (PLAN §2.2.1), cheapest and
safest first:

  0  post-patch text already present  -> no-op, keeps re-runs idempotent
  1  whole file matches the pre-image -> apply verbatim
  2  file differs, patched region is still byte-identical -> apply at the
     matched offset
  3  anything else -> do not touch the file; print the hunk to apply by hand

Rung 2 needs no new metadata: a unified diff already carries the stock text of
the region it edits (context lines plus the '-' lines). Guardrails keep it from
becoming a fuzzy apply -- exact match with only CRLF/trailing-whitespace
normalisation, exactly one occurrence or it fails, line numbers advisory only,
and all-or-nothing per patch file so a half-patched EventSink.cs cannot happen.
install.json records which rung applied each patch, and doctor and uninstall
report it.

This retires the blanket 57.4-only version gate, so PLAN gains §2.2.2 to draw
the line the ladder does not: content matching is a mechanical guarantee about
where text lands, not a support commitment. 57.4 stays the only supported
version. A non-57.4 tree may attempt the tier, but unsupported, untested and not
guaranteed -- behind a loud banner, a prompt defaulted to no, and its own
--patches-unsupported-servuo flag, because a bare --patches can be hit by
accident in a copied script. The unsupported marker persists into install.json,
every later doctor run, and the uninstall report.

INSTALL.md gets the operator-facing half: a block-quoted warning naming the
silent-script-build failure mode, the updated prerequisite row, prompts and flag
table, and a sample run showing all three outcomes.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 14:17:33 -05:00
d5838f7d4c Merge pull request 'docs(installer): mark Phase 0.4 merged in the status table' (#88) from docs/installer-0.4-status into main
Reviewed-on: #88
2026-08-04 19:03:55 +00:00
90459f2c49 docs(installer): mark Phase 0.4 merged in the status table
docs#87 landed; the row it added still said 'in review'. The status table is
the first thing anyone reads in this file, so a stale row there is worse than
no row.
2026-08-04 14:02:32 -05:00
a2e859bf9a Merge pull request 'docs(tree): sync installer/PROJECT_TREE.md' (#86) from chore/sync-installer-tree into main
Reviewed-on: #86
2026-08-04 19:01:27 +00:00
2dbe4d8388 Merge branch 'main' into chore/sync-installer-tree 2026-08-04 17:15:57 +00:00
e85ca632ce Merge pull request 'docs(installer): add the operator install guide (Phase 0.4)' (#87) from docs/installer-phase-0.4 into main
Reviewed-on: #87
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-04 17:10:33 +00:00
83bd4ec2d4 docs(installer): link the Phase 0.4 PR from the status table 2026-08-04 11:40:06 -05:00
5c8585fe75 docs(installer): add the operator install guide (Phase 0.4)
Closes the last Phase 0 item. INSTALL.md is written before the installer
binary on purpose: everything it installs is already released (0.1-0.3), so
the guide is not speculation about a tool that might exist - it is the
specification of what the run asks, where it writes, what it prints, and what
the operator does next.

It is useful today. Appendix A is the same deployment done by hand - bundle
fetch, tarball verify and overlay copy, the optional patch tier,
--print-config provisioning, systemd unit / sc create - composed from the
released artifacts' actual contents and the sidecar's config and CLI source.
That appendix doubles as Phase 1's acceptance test.

Writing it settled four things the plan had left implicit, now recorded in
PLAN.md:

- The installer does not install itself; day-two commands run from the
  downloaded binary.
- The flag surface: --servuo, --patches/--no-patches, --host, --site-url and
  --yes join the --verify/--bundle/--purge the plan already named, so every
  prompt has a non-interactive equivalent.
- A modified Config/Bridge.cfg is reported, not overwritten - one deliberate
  deviation from deploy.ps1, whose overwrite-on-hash-differs rule is right for
  a developer and would silently revert an operator's whole shard config on
  update. install.json's recorded hashes are what make the distinction
  possible.
- Remote-website deployments: widen [web] bind, firewall it to the site's
  address, front it with TLS or a VPN off a trusted network. [shard] bind
  stays on loopback because that socket carries commands into the game.

Also adds the missing installer/ section to the docs index, and refreshes two
stale examples in PLAN.md (overlay file count, sidecar version).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 11:39:02 -05:00
fc79bb6ed0 Merge pull request 'docs(installer): record Phase 0.3 — the bundle CI and where bundles live' (#85) from docs/installer-phase-0.3 into main
Reviewed-on: #85
2026-08-04 16:20:05 +00:00
runic-docs-bot
a6b20cb273 docs(tree): sync installer/PROJECT_TREE.md from RunicGateway/installer@0e7d5f3 [skip ci] 2026-08-04 16:19:51 +00:00
af6036b9c7 Merge pull request 'docs(tree): sync link/PROJECT_TREE.md' (#83) from chore/sync-link-tree into main
Reviewed-on: #83
2026-08-04 16:17:52 +00:00
e0245a209c Merge branch 'main' into chore/sync-link-tree 2026-08-04 16:17:39 +00:00
10ebce706f docs(installer): record Phase 0.3 — the bundle CI and where bundles live
Documentation half of installer Phase 0.3. Code PRs: RunicGateway/installer#3,
RunicGateway/link#25, RunicGateway/servuo-plugins#9.

Phase 0 item 3 asked for a compose job, a nightly cron, and a dispatch step in
each component. All three landed, and building them settled questions §7 had
left open — so those sections are corrected rather than appended to.

Status: Phase 0 is now all but complete. 0.2 is merged and released as link
v1.1.0; 0.3 is in review; 0.4 (INSTALL.md) is the remaining item, and the shape
it was waiting on has settled.

Phase 0 item 3 gains an "As built" subsection matching items 1 and 2, recording
what was chosen rather than inherited: why gate 1 reads the sidecar's protocol
from source at the release tag instead of asking the binary via --print-config
(it only answers for v1.1.0+, and --bundle <tag> must be able to recompose an
older pair); why gate 2 records the hash CI computed itself and separately
checks for assets absent from SHA256SUMS; why release metadata is read
anonymously; why an unrecognized asset name is a hard failure; and why a run
that changes nothing writes nothing.

§7.1 is corrected in two places. The manifest shape now shows link.assets as a
map keyed by platform — the single sha256 this section sketched could only ever
have described one of the two binaries link publishes — plus schema/generated
and the servuo block. And it gains a subsection naming where bundles are
published: committed under bundles/ in the installer repo, NOT one Gitea release
per bundle, because that repo's own releases are the installer binaries and
/releases/latest returns whichever release is newest regardless of kind.

§7.2 records that the dispatch steps now exist, and that a failed dispatch is a
warning rather than a failed release — which is what keeps the installer repo's
token out of the components' hard requirements.

§7.3 records that the releasable-commit rule must also exclude merge commits,
whose subject quotes the real one: without that, merging a docs: branch whose
title mentions a fix: would re-dispatch a declining release workflow nightly.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 11:17:28 -05:00
d1cb3e9511 Merge pull request 'docs(installer): record Phase 0.2 — the sidecar's CLI and settled data paths' (#84) from docs/installer-phase-0.2 into main
Reviewed-on: #84
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-04 15:55:52 +00:00
c1957f0bfb docs(installer): record Phase 0.2 — the sidecar's CLI and settled data paths
Phase 0.2 landed in link#24: the sidecar gained a four-flag CLI, --print-config,
and config-anchored data paths. Three sections of the installer plan asserted
facts that change as a result, so they are corrected rather than appended to.

- Status table: 0.1 merged (servuo-plugins#7/#8, overlay v0.1.1 released), 0.2 in
  review, repo bootstrap merged. 0.3 (bundle CI) is next and now unblocked — both
  components it composes exist.
- Phase 0 item 2 gains an "As built" subsection matching item 1's: why four
  hand-rolled flags rather than a parsing crate, why --print-config provisions
  instead of only reporting, why config_created/token_generated exist, and why no
  platform data directories are compiled into the binary.
- §2.3 (working-directory trap): half-closed in the sidecar — a relative
  [store].path now anchors to the config file's directory — while the service
  definitions still pin both env vars, and why that is not redundant.
- §2.4 (token handoff): the installer reads the handoff block out of one
  --print-config call and never parses the log, which is not a contract.
- §5 Phase 2 / Phase 4, §6: the ordering that follows (print-config before service
  registration), which doctor rows the CLI answers, that only the host is
  substituted into the printed URLs because web.bind is often 0.0.0.0, and that the
  printed token must not reach a log or support bundle.
- link/INTEGRATION.md §1: how to read the token back, replacing "the sidecar logs
  it" with the supported command and its output.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 10:48:44 -05:00
runic-docs-bot
b6343a0937 docs(tree): sync link/PROJECT_TREE.md from RunicGateway/link@654a08a [skip ci] 2026-08-04 15:48:24 +00:00
e1608bb280 Merge pull request 'docs(installer): record Phase 0 progress and the overlay manifest' (#82) from docs/installer-phase0 into main
Reviewed-on: #82
2026-08-04 15:29:17 +00:00
f0975df598 docs(installer): record Phase 0 progress and the overlay manifest
Tracks what actually landed while starting the installer plan, and corrects
the parts of the plan that the work proved wrong or stale.

Progress:

  A Phase 0 status table at the top, so the plan says where it is rather
  than needing a reader to reconstruct it from PR links.

  Phase 0 item 1 (§5) now records the release workflow as built, including
  its three deviations from link's copy — structural gates instead of build
  gates, no bump commit and therefore no push to main, and overlay.toml as
  the home for the declared protocol version. Plus the fixed tarball prefix
  and why: the installer would otherwise have to parse the version it is
  trying to read.

New §7.0 documents the overlay manifest as generated, and states plainly the
two things about it that carry weight: `protocol` is hand-maintained and has
to be (nothing in CI can derive it, which is exactly why §7.1's gate 1 has
something to compare), and `files` is what lets `doctor` distinguish
"operator edited a deployed file" from "the overlay moved on".

Corrections:

  §2.6 the plugin's protocol version now has a home (overlay.toml), and
        servuo-plugins now has a release workflow.
  §7.2  the dispatch step is deliberately deferred to Phase 0 item 3.
  §7.4  no longer "open risk" — the v3 cutover merged. The rule it motivated
        (never hardcode a protocol version) is restated as permanent rather
        than as a workaround for a mid-flight cutover.
  §8    open question 4 (branch targeting) resolved: servuo-plugins#6 merged,
        main == edge, everything targets main.

Version examples in §3, §5 and §7.1 said uo-link v3.x.y / 3.0.1, conflating
the release version with the protocol version. link is actually at v0.3.0 —
the two are independent, and the bundle names release versions, so an example
implying they track each other is actively misleading. Now uses the real
values (link 0.3.0, overlay 0.1.0, 30 overlay files).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 09:28:23 -05:00
8322e8318c Merge pull request 'docs(installer): add release orchestration and the bundle manifest' (#81) from docs/installer-release-orchestration into main
Reviewed-on: #81
2026-08-01 21:42:33 +00:00
25a5734107 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:11:08 -05:00
c583ddb77f Merge pull request 'docs(installer): plan the Runic Gateway installer' (#80) from docs/installer-plan into main
Reviewed-on: #80
2026-08-01 10:46:13 +00:00
29056ba996 docs(installer): plan the Runic Gateway installer
Design of record for a deployment tool that takes a stock ServUO install and
configures it for Runic Gateway. Supersedes the informal overview it grew from,
which described a ServUO integration that does not match how servuo-plugins
actually ships.

Corrections that change the design:

- There is no RunicGateway.dll and no Plugins/ dir. The plugin ships as C#
  source compiled by ServUO at boot, so the step is a hash-compare sync of
  overlay/ -- but a successful copy does not mean a working bridge, because
  ScriptCompiler.Compile() ignores dotnet build's exit code and reloads the
  stale Scripts.dll.
- Stock ServUO files ARE modified, by three diffs in patches/. Made an opt-in,
  skippable tier: git apply against a hand-modified shard will fail, and the
  EventSink.cs patch needs a full core solution rebuild.
- Config paths collided with what the sidecar actually reads. Split ownership:
  sidecar.toml stays the sidecar's schema, install.json is the installer's.
  Service definitions pin UOLINK_CONFIG and UOLINK_DB_PATH, since the sidecar
  writes relative to CWD and would land in VirtualStore under Program Files.
- The token handoff was missing entirely -- the largest "installed it and
  nothing happened" failure mode.
- deploy.ps1 cannot be the cross-platform deployer; it stays the developer tool.

Decisions: public audience, unsigned binaries anchored on SHA256SUMS, Rust,
release-tarball plugin distribution, printed token handoff, new installer repo,
warn-and-skip on non-57.4 ServUO, and an uninstall that never edits the shard
tree -- it prints the files to delete and the hunks to revert.

Phases 0-5, with Phase 0 (a release workflow for servuo-plugins, which has
none today) gating everything else. Two open questions remain in section 8.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 05:44:44 -05:00
186f057bc0 Merge pull request 'docs(tree): sync android/PROJECT_TREE.md' (#79) from chore/sync-android-tree into main
Reviewed-on: #79
2026-08-01 07:50:46 +00:00
runic-docs-bot
33a013ca98 docs(tree): sync android/PROJECT_TREE.md from RunicGateway/Android-app@5eaf5d2 [skip ci] 2026-08-01 07:22:23 +00:00
5e2bc22a94 Merge pull request 'docs(tree): sync link/PROJECT_TREE.md' (#77) from chore/sync-link-tree into main
Reviewed-on: #77
2026-08-01 07:21:29 +00:00
04cd64b838 Merge branch 'main' into chore/sync-link-tree 2026-08-01 07:21:09 +00:00
916c11ee92 Merge pull request 'docs(tree): sync website/PROJECT_TREE.md' (#78) from chore/sync-website-tree into main
Reviewed-on: #78
2026-08-01 07:20:49 +00:00
runic-docs-bot
ea3755760d docs(tree): sync website/PROJECT_TREE.md from RunicGateway/website@5103b74 [skip ci] 2026-08-01 07:19:52 +00:00
runic-docs-bot
54b3002701 docs(tree): sync link/PROJECT_TREE.md from RunicGateway/link@295defb [skip ci] 2026-08-01 06:50:30 +00:00
9d98109628 Merge pull request 'docs!: Protocol 3.0 cutover — the 3.0 documentation set' (#73) from edge into main
Reviewed-on: #73
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-01 06:29:36 +00:00
d0808b766b Merge branch 'main' into edge 2026-08-01 06:28:21 +00:00
eab0a83f26 Merge pull request 'docs(link): the shard-name fallback, the atlas places shape, and two traps' (#76) from docs/protocol-3-smoke-findings into edge
Reviewed-on: #76
2026-08-01 06:02:59 +00:00
1444c77413 docs(link): the shard-name fallback, the atlas places shape, and two traps
Records what the live Protocol 3.0 smoke test (ServUO + sidecar + website +
AVD) turned up, so none of it has to be rediscovered.

v3.md §5.3 — the ruleset `shard` field now falls back to the instance's own
name when the shard publishes ServUO's stock "My Shard", why that is done at
ingest rather than on read (the frame is also broadcast live), and why the
backfill snapshot must go through the dispatcher instead of writing state
directly: a direct call made it a second writer that skipped the
normalization.

v3.md §7.4 — an unscored board renders a placeholder row rather than a blank
card, and why it is deliberately not shaped like a real entry.

PLAN.md §9 M11 — `places` is a list of {facet,label,spawners,maxAlive} OBJECTS,
not of place-name strings, and typing it `List<String>` makes the whole detail
route fail to decode while the request itself returns 200. Adds the rule that
came out of it: decode tests must feed real captured JSON, because the fakes
build DTOs in Kotlin and can never catch a wire mismatch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bP
2026-08-01 00:58:53 -05:00
2e24427032 Merge pull request 'docs(website): an operator runbook for extracting from your own UO client' (#75) from docs/operator-uofiddler-guide into edge
Reviewed-on: #75
2026-07-30 09:51:31 +00:00
afcdb373ec docs(website): an operator runbook for extracting from your own UO client
CLILOCS.md and SPAWN_ATLAS.md each explain WHY the operator has to supply
something out of their own client, but neither says how. UOFIDDLER.md is the
missing procedure: where to get UOFiddler, which two files in the zip matter,
which runtime it needs, where Cliloc.enu actually lives, the conversion, how to
point the site at the result, and how to confirm it took.

Verified end to end on a stock Windows box: UOFiddler 4.22.2 (Ultima.dll is
net10.0), .NET SDK 9.0.312 building the net8.0 converter, RollForward carrying
it onto runtime 10.0.8, and the site's own parser reading the output back.

Corrects one claim while doing it. CLILOCS.md said a UOFiddler GUI export
"works equally well"; it does not. Its Cliloc tab writes `Number;Text;Flag` --
three columns, flag LAST -- and parseClilocText splits on the first separator
only, so the flag is absorbed into the name and every item renders as
`quarter staff;0`. The parser already handles `number,flag,text` with the flag
in the middle, but a trailing `;0` is indistinguishable from a name that
genuinely ends that way, so this stays a documented `sed` on the operator's
side rather than a heuristic that would corrupt real names.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-30 03:34:29 -05:00
45fb4a3f15 Merge pull request 'docs(android): scope M11 — Protocol 3.0 shard parity for the app' (#74) from docs/android-v3-parity into edge
Reviewed-on: #74
2026-07-30 07:23:00 +00:00
5a091157d6 docs(android): scope M11 — Protocol 3.0 shard parity for the app
The v3 work added four shard features and an admin-configurable visibility
framework the Android client knows nothing about. v3.md §10 deferred the app
side as a follow-up; re-examining it before the cutover found the gap is wider
than nav hiding:

  - no consumer for any of ruleset / leaderboards / market / atlas,
  - no `points` block on the character sheet (§7.3),
  - no cliloc-resolved item names (§8.6), and
  - shard nav gated on session role alone, so an admin who disables a feature
    or raises its audience leaves the app rendering entries that 404/403 into a
    generic error where the web client hides them.

Scoped as PLAN.md §9 M11 in two PRs (the visibility rules + read-model adds,
then the four screens), with the traps a real shard exposes recorded inline:
uncapped `maxPoints: 0`, cliloc-named boards with a null `nameString`, skill
caps in tenths, the required market staleness banner, the market stream being
off by default, atlas delays in seconds, and `points`-count vs `spawners`-list.

edge → main is held until both land so web and app surface the same shard on
the same day. Neither PR is coupled to the merge order — on a pre-v3 website
every new route and /public/shard/features 404s and the app falls back to
today's behavior — so holding the cutover is a schedule decision, not a
technical dependency.

Also records two things verified as already correct, so they are not
re-derived: the app's SSE request rides the authenticated client (same audience
rung as the same account on web), and every shard DTO is nullable-with-defaults
(field projection cannot cause a decode failure).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-30 00:46:16 -05:00
3a1bbdd165 Merge pull request 'docs(link): the Protocol 3.0 cutover (v3.md order 6)' (#72) from docs/protocol-3-cutover into edge
Reviewed-on: #72
2026-07-30 03:03:10 +00:00
32def88c4e docs(link): fill in the cutover PR numbers
The order-6 row was written before the seven PRs existed. Same follow-up as the
cliloc row got.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 18:09:50 -05:00
71207cef16 docs(link): the Protocol 3.0 cutover (v3.md order 6)
INTEGRATION.md was written for the window that just closed -- it told integrators
the version had NOT been bumped yet and that a sidecar on `edge` reports 2 while
already carrying v3 kinds. That guidance is now wrong in the direction that
matters, so the version section states 3 (header, /health, ws.hello, the 409
example and the §8 worked example) and replaces the "until then" paragraph with
what a v2 integration actually has to do to upgrade: change the constant it
sends, and nothing else, because nothing that existed in v2 changed shape.

v3.md gains §4.1 for what the bump touches and, more importantly, WHY the
website's boot migration is gated on a marker row: schema.sql is re-run on every
boot and uo_link_config.protocol is admin-editable, so an ungated UPDATE would
silently un-pin an operator running an older sidecar. That is the one piece of
the cutover a reader could not infer from the code being one constant.

Progress tables: 5b done, 6 in review.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 18:04:00 -05:00
06b4a06baa Merge pull request 'docs(tree): sync website/PROJECT_TREE.md' (#59) from chore/sync-website-tree into main
Reviewed-on: #59
2026-07-28 15:02:41 +00:00
runic-docs-bot
f2fa6abff7 docs(tree): sync website/PROJECT_TREE.md from RunicGateway/website@a3407ae [skip ci] 2026-07-28 06:12:43 +00:00
19 changed files with 4768 additions and 56 deletions

View File

@@ -7,21 +7,28 @@ so they live in one place, independent of either codebase.
## Layout ## Layout
``` ```
website/ docs from the shard website (Node/Express + MariaDB + React/Vite) website/ docs from the shard website (Node/Express + MariaDB + React/Vite)
link/ docs from the ServUO bridge (C# plugin + Rust sidecar + Node WS) link/ docs from the ServUO bridge (C# plugin + Rust sidecar + Node WS)
android/ docs from the native Android client (Kotlin + Jetpack Compose) android/ docs from the native Android client (Kotlin + Jetpack Compose)
ci/ cross-cutting CI/quality notes installer/ docs for the installer that deploys a shard's bridge components
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/` ### `website/`
| Doc | What it covers | | Doc | What it covers |
|---|---| |---|---|
| [BACKEND_DESIGN.md](website/BACKEND_DESIGN.md) | API contract, DB schema, security model | | [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 | | [HERO_EDITOR.md](website/HERO_EDITOR.md) | Hero canvas editor feature spec |
| [THEMING_AND_NAV.md](website/THEMING_AND_NAV.md) | Admin-configurable theme, brand assets and navigation — build contract |
| [WIKI_UPGRADE.md](website/WIKI_UPGRADE.md) | Wiki subsystem upgrade notes | | [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 | | [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 | | [SPAWN_ATLAS.md](website/SPAWN_ATLAS.md) | The bestiary / spawn atlas: what the shard contains, parsed from its own ServUO tree |
| [CLILOCS.md](website/CLILOCS.md) | UO's id → name table: converting one from your client so items have names | | [CLILOCS.md](website/CLILOCS.md) | UO's id → name table: converting one from your client so items have names |
| [UOFIDDLER.md](website/UOFIDDLER.md) | **Operator runbook** — step-by-step extraction from your own UO client (cliloc table, creature art) |
| [MARKETPLACE.md](website/MARKETPLACE.md) | The player-vendor index: how it is gathered, what it costs, how to tune it | | [MARKETPLACE.md](website/MARKETPLACE.md) | The player-vendor index: how it is gathered, what it costs, how to tune it |
| [website-README.md](website/website-README.md) | Snapshot of the website repo's README (setup/run reference) | | [website-README.md](website/website-README.md) | Snapshot of the website repo's README (setup/run reference) |
| [PROJECT_TREE.md](website/PROJECT_TREE.md) | Auto-generated snapshot of the repo's tracked file layout | | [PROJECT_TREE.md](website/PROJECT_TREE.md) | Auto-generated snapshot of the repo's tracked file layout |
@@ -49,6 +56,12 @@ ci/ cross-cutting CI/quality notes
| [TRUSTED_DEVICES_APP_HANDOFF.md](android/TRUSTED_DEVICES_APP_HANDOFF.md) | Trusted-devices app handoff notes | | [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 | | [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 ## Provenance
- `website/*` was extracted from `RunicGateway/website` via `git filter-repo`. - `website/*` was extracted from `RunicGateway/website` via `git filter-repo`.

View File

@@ -1,6 +1,6 @@
# Android App — Plan # Android App — Plan
Status: **M0M7 landed; M7 (push notifications) both parts done — Part 1 backend (website#78) and Part 2 app (Android-app#15) plus a small `push.ntfyUrl` settings addition (website#79). Remaining: set the shard's `NTFY_*` deploy config so push lights up, and cut the v1 tag. M9 (native SSO login) is now underway backend-first — the Mobile SSO Authorization Bridge is being built in `website/` + `docs/` ahead of the app-side client (§4.2, §9 M9); custom-scheme callback only for now, App Links deferred (see [`APP_LINKS.md`](./APP_LINKS.md)).** This document is the Status: **M0M7 landed; M7 (push notifications) both parts done — Part 1 backend (website#78) and Part 2 app (Android-app#15) plus a small `push.ntfyUrl` settings addition (website#79). Remaining: set the shard's `NTFY_*` deploy config so push lights up, and cut the v1 tag. M9 (native SSO login) is now underway backend-first — the Mobile SSO Authorization Bridge is being built in `website/` + `docs/` ahead of the app-side client (§4.2, §9 M9); custom-scheme callback only for now, App Links deferred (see [`APP_LINKS.md`](./APP_LINKS.md)). **M11 (Protocol 3.0 shard parity)** is scoped and next: the app sees none of the four shard features v3 added (`ruleset`, `leaderboards`, `market`, `atlas`) and does not consult `GET /public/shard/features`, so it gates shard nav on session role alone while an admin can switch any of those surfaces off or raise its audience — the v3 `edge``main` cutover is held until it lands (§9 M11).** This document is the
design contract for the `RunicGateway/Android-app` repo. It was written before implementation so the design contract for the `RunicGateway/Android-app` repo. It was written before implementation so the
API changes it depends on could be landed in `website/` and `docs/` first. The authoritative API API changes it depends on could be landed in `website/` and `docs/` first. The authoritative API
reference is the committed OpenAPI spec at `website/server/swagger/swagger-output.json` (regenerated reference is the committed OpenAPI spec at `website/server/swagger/swagger-output.json` (regenerated
@@ -631,6 +631,8 @@ not rank).
| News & content | everyone | `/public/posts/:category`, `/public/pages/:slug` | | News & content | everyone | `/public/posts/:category`, `/public/pages/:slug` |
| Wiki | everyone | `/public/wiki`, `/public/wiki/categories`, `/public/wiki/tags`, `/public/wiki/:slug` | | Wiki | everyone | `/public/wiki`, `/public/wiki/categories`, `/public/wiki/tags`, `/public/wiki/:slug` |
| Shard (live) | everyone | `/public/shard/*` + `/public/shard/stream` (SSE) | | Shard (live) | everyone | `/public/shard/*` + `/public/shard/stream` (SSE) |
| **Rules / Leaderboards / Market** | everyone, *if the shard publishes them* | `/public/shard/{ruleset,points,market}` (M11) |
| **Atlas** (bestiary) | everyone, *if the shard publishes it* | `/public/atlas/*` (M11) |
| Contact | everyone | `/public/contact` | | Contact | everyone | `/public/contact` |
| **My Account** | signed-in | `/player/account/*` (or `/admin/account/*` for staff — see §6.4) | | **My Account** | signed-in | `/player/account/*` (or `/admin/account/*` for staff — see §6.4) |
| **My Characters / Vendors / Houses** | `player` (linked) | `/player/shard/*` | | **My Characters / Vendors / Houses** | `player` (linked) | `/player/shard/*` |
@@ -639,6 +641,12 @@ not rank).
Guidelines: Guidelines:
- The menu is **declarative + data-driven**, not a pile of `if role ==` checks — one list of entries - The menu is **declarative + data-driven**, not a pile of `if role ==` checks — one list of entries
with a `minAccess`/`requiredCapability` field, filtered by the session. with a `minAccess`/`requiredCapability` field, filtered by the session.
- **Session role is not the only gate on shard surfaces (M11).** Every shard-derived feature is
*admin-configurable* — it can be switched off or raised to a higher audience rung — so a shard entry
is filtered by the session role **and** by `GET /public/shard/features`, which reports the features
the caller may actually reach. While that answer is unknown (in flight, or the lookup failed) the app
shows everything: the server gates regardless, and a nav that flickers in on every load is worse than
a link that briefly `403`s.
- Never hide the fact that more exists behind auth in a way that misleads; anonymous users see public - Never hide the fact that more exists behind auth in a way that misleads; anonymous users see public
groups and a "Sign in" affordance. groups and a "Sign in" affordance.
- The server is the source of truth: a hidden/greyed item is a UX convenience; every gated call still - The server is the source of truth: a hidden/greyed item is a UX convenience; every gated call still
@@ -663,9 +671,15 @@ Guidelines:
### 6.2 Public shard (live) ### 6.2 Public shard (live)
- Status/online/feed/economy/champs/guilds/governors(+history)/presence/houses/idoc — the - Status/online/feed/economy/champs/guilds/governors(+history)/presence/houses/idoc — the
`/public/shard/*` GETs. `/public/shard/*` GETs.
- **Live updates** — subscribe to `GET /public/shard/stream` (SSE, safe kinds only) and patch the - **Live updates** — subscribe to `GET /public/shard/stream` (SSE) and patch the in-memory boards in
in-memory boards in place (champ/guild/city/house/presence update+remove frames). Reconnect with place (champ/guild/city/house/presence update+remove frames). Reconnect with backoff; fall back to
backoff; fall back to poll if SSE drops. poll if SSE drops. What arrives on the stream is **resolved from the caller's audience rung at
subscribe time**, not from a fixed allowlist (Protocol 3.0 §3.6) — the stream request carries the
bearer like every other call, so a signed-in app session sees exactly what the same account sees on
the web.
- **Visibility + the Protocol 3.0 surfaces (M11)** — `GET /public/shard/features` drives which of these
the menu offers; `GET /public/shard/{ruleset,points,points/:system,market,market/meta,market/vendors/:serial}`
and `GET /public/atlas/*` are the new reads. Full contract and traps in §9 M11.
### 6.3 Player self-service & game data (bearer) ### 6.3 Player self-service & game data (bearer)
- **Account** — `GET /player/account`; `PATCH /player/account/username`; - **Account** — `GET /player/account`; `PATCH /player/account/username`;
@@ -675,6 +689,11 @@ Guidelines:
- **My game data** — `GET /player/shard/roster/:account`, `/char/:serial`, `/vendors/:account`, - **My game data** — `GET /player/shard/roster/:account`, `/char/:serial`, `/vendors/:account`,
`/sales`, `/houses`. All ownership-checked server-side; a `503` means shard/sidecar down → show an `/sales`, `/houses`. All ownership-checked server-side; a `503` means shard/sidecar down → show an
"offline, retry" state (see §7). "offline, retry" state (see §7).
- **The character sheet carries two things the app does not yet read (M11):** the `points` block
(per-character loyalty/points standings, Protocol 3.0 §7.3) and the server-resolved cliloc names on
`equipment[].clilocName` / `titles.rewardResolved` (§8.6). Both are served **ungated** on this route —
a character's own standings are self-service data and do not depend on the public `leaderboards`
feature being visible, which is the behavior the app must mirror rather than re-gate.
- **Presentation is text-only for v1.** Character sheets and vendor listings render as data/text — no - **Presentation is text-only for v1.** Character sheets and vendor listings render as data/text — no
item icons or paperdoll art. A richer "pretty paperdoll" view is a **future** enhancement (pending the item icons or paperdoll art. A richer "pretty paperdoll" view is a **future** enhancement (pending the
art/asset work on the platform side) and is explicitly out of the first release. art/asset work on the platform side) and is explicitly out of the first release.
@@ -883,10 +902,147 @@ push, and Play (M6M8) follow the designed app.
shard-write actions degrade gracefully when the sidecar is offline. Excluded: hero/CMS block shard-write actions degrade gracefully when the sidecar is offline. Excluded: hero/CMS block
editor, Discord-bot config, uo-link config, OAuth-provider setup. editor, Discord-bot config, uo-link config, OAuth-provider setup.
12. **M11 — Protocol 3.0 shard parity** (post-v1; scoped 2026-07-30). The website's Protocol 3.0 work
added four shard features and, with them, an **admin-configurable visibility framework** the app
knows nothing about. `link/v3.md` §10 deferred the app side as a follow-up; it is now scoped
deliberately, and **the v3 `edge` → `main` cutover is held until both parts land** so web and app
surface the same shard on the same day (decided 2026-07-30).
Neither part is coupled to the cutover *merge order*, which is what makes holding it a schedule
decision rather than a technical one: against a pre-v3 website every new route and
`/public/shard/features` simply `404`s, and each consumer below falls back to exactly today's
behavior. The app declares no protocol version and never talks to the sidecar.
- **Part 1 — the visibility rules + the read-model adds.** The security-shaped half, reviewed on
its own:
- `GET /public/shard/features` → `{ level, features[] }`: the features **this caller** may reach.
A new singleton cache mirrors the web client's (`lib/useShardFeatures.js`): per-viewer but
stable for a session, invalidated on sign-in/out and on a server switch.
- `MenuEntry` gains `feature: String?` beside its existing `access`, so the one declarative menu
(§5) filters on the session role **and** the shard's live feature config. While the lookup is
in flight or has failed, **show everything** — the same deliberate fail-open the web client
takes, because the server gates regardless and a nav that flickers in on every load is worse
than a link that briefly `403`s. The gate is server-side; hiding is presentation.
- **`404` and `403` mean different things here** and neither is a generic error:
`requireFeature` `404`s a *disabled* feature (deliberately not disclosing that it exists) and
`403`s a viewer *below its audience*. Both render "not available on this shard", alongside the
existing `503` = shard offline (§7).
- **The `level` from `/features` is authoritative — do not re-derive the rung from the role.**
The server's ladder is `anonymous → logged_in → player → staff → admin`, where `player` means
*a linked game account* and staff always satisfy `player` (the same superset rule `Menu.kt`
already encodes as `isPlayer || isStaff`).
- **`char.profile.points`** → the "Loyalty & Points" block the web character sheet gained:
`CharProfileDto.points[{system, nameString, points, maxPoints, rank?}]`. Three traps, all of
them things a real shard does and a fake one does not (`v3.md` §7.5): `maxPoints == 0` means
**uncapped** and is the *common* case, so nothing may divide by it; `nameString` is usually
`null` because most systems name themselves with a cliloc, making the humanise-the-`system`-key
path the **primary** one rather than a fallback; and `rank` is absent unless the shard runs
`PointsProfileRank=true` — absent and "unranked" are different answers.
- **Cliloc-resolved names** (`v3.md` §8.6, already live on the website): `EquipmentDto` gains
`name` + `clilocName` and `TitlesDto` gains `rewardResolved`, so equipment stops rendering as a
layer or a bare id. Precedence is `name → clilocName → layer`: a player-given name outranks the
resolved type name, and the server applies the same order. A shard with no cliloc table
configured sends neither field and the sheet renders exactly as it does today.
- `ActorDto` keeps its `acct` / `webId` fields (nullable, so nothing breaks) but its KDoc stops
describing them as available: they are **locked to the admin rung**, always, and stripped from
every response below it.
- **Part 2 — the four new screens**, each hidden by its feature name in the menu:
- **Rules** — `GET /public/shard/ruleset` (`ruleset`). A `null` body means "the shard has not
published its ruleset yet", which is a different state from the feature being disabled. Every
block is optional and omitted when its system is off. **`caps.skill` / `caps.totalSkill` are in
tenths** (1000 = 100.0) and must be converted — the raw number is actively misleading, not
merely unhelpful. Live via the `world.ruleset` frame, which is on the public stream by default.
- **Leaderboards** — `GET /public/shard/points`, `/points/:system` (`leaderboards`). The same
`maxPoints`/`nameString` traps as the profile block. Live via `points.board`.
- **Market** — `GET /public/shard/market` (`q`, `minPrice`, `maxPrice`, `itemId`, `map`, `region`,
`sort`, `limit`, `offset`), `/market/meta` for the filter options + staleness, and
`/market/vendors/:serial` (`market`). Four things this screen must get right: it is the site's
first **rate-limited** public endpoint, so handle `429` the way the contact form does; the
*"prices last refreshed N minutes ago"* banner is **required, not decoration** — the shard
sweeps vendors round-robin, so a listing can legitimately be a full cycle stale and a page
implying live prices sends people to an item that sold twenty minutes ago; a `truncated` shop
must say so; and `location` is a **nested object** that an admin may gate away entirely, which
the vendor screen renders as "hidden by the shard" (a real answer) rather than as blank
coordinates — same for `ownerName` / `ownerSerial`. **The `market` SSE fan-out is off by
default** (a live firehose of vendor inventories would be the site's biggest bandwidth
consumer), so the screen is a plain paginated read and must never depend on live frames.
- **Atlas** — `GET /public/atlas/{creatures,creatures/:slug,regions,landmarks,champions,meta}`
(`atlas`). Note the path: `/public/atlas`, **not** `/public/shard` — the atlas is static shard
*content*, not live shard *state*, and unlike `/shard/*` it **is** `siteMode`-gated like
`/posts` and `/wiki`, so a site in maintenance mode withholds it independently of the sidecar.
Three traps. Two are units/naming, from `v3.md` §6.3: respawn delays are **seconds**
throughout, and `points` is a *count* on the search route while `spawners` is the *list* on
the detail route. The third is a **shape**: `places` is a list of
`{facet, label, spawners, maxAlive}` **objects**, not of place-name strings — it is the
aggregate the screen exists to show ("Shrines, Isamu-Jima, Yew"), it arrives only on the
detail route, and typing it `List<String>` makes that whole route fail to decode while the
request itself returns `200`.
- **Verification** — the five-rung walk (`anonymous`, `logged_in`, `player`, `staff`, `admin`)
against a local website on the cutover branch, per
[`../link/v3.md`](../link/v3.md) §11 and the shard-visibility smoke harness; plus one pass with
**every feature disabled** in Admin → Shard Visibility, confirming the app *hides* each surface
instead of erroring on it. Unit tests cover the menu filter (role × feature set), the
`404`/`403`/`503` mapping, and DTO decode for each new shape. **Decode tests must feed real
captured JSON**, not DTOs built in Kotlin: the fakes under `data/api/fake/` construct objects
directly, so they can never catch a wire/type mismatch — which is how the `places` shape above
shipped past a green suite.
- **Excluded**, in the same class as M10's exclusions: the admin *configuration* panels — Shard
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.52.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 is scheduled **last and marked optional**, so it can be dropped on its merits
once the rest is working.
- **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.
Nine phases 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.
### Deferred (not a milestone) ### Deferred (not a milestone)
- **`/api/mobile` facade migration + app-version floor** — briefly planned as M11 (2026-07-22), now - **`/api/mobile` facade migration + app-version floor** — briefly planned as its own milestone
**deferred with no app work scheduled**. The website's router refactor is being done in place with (2026-07-22), now **deferred with no app work scheduled**. The website's router refactor is being done in place with
every URL byte-identical and `/api/v1` is not being retired, so the app's ~70 hardcoded `api/v1/…` every URL byte-identical and `/api/v1` is not being retired, so the app's ~70 hardcoded `api/v1/…`
endpoints, its SSE path, and its SSO URLs keep working untouched. If the mobile contract ever needs endpoints, its SSE path, and its SSO URLs keep working untouched. If the mobile contract ever needs
to diverge from web, the migration comes back — starting from a one-line alias mount on the server, to diverge from web, the migration comes back — starting from a one-line alias mount on the server,

View File

@@ -85,6 +85,7 @@ android-app/
│ │ │ │ │ │ │ ├── PlayerShardDto.kt │ │ │ │ │ │ │ ├── PlayerShardDto.kt
│ │ │ │ │ │ │ ├── PostDto.kt │ │ │ │ │ │ │ ├── PostDto.kt
│ │ │ │ │ │ │ ├── PublicDto.kt │ │ │ │ │ │ │ ├── PublicDto.kt
│ │ │ │ │ │ │ ├── ShardContentDto.kt
│ │ │ │ │ │ │ ├── ShardDto.kt │ │ │ │ │ │ │ ├── ShardDto.kt
│ │ │ │ │ │ │ ├── SsoDto.kt │ │ │ │ │ │ │ ├── SsoDto.kt
│ │ │ │ │ │ │ └── WikiDto.kt │ │ │ │ │ │ │ └── WikiDto.kt
@@ -106,6 +107,7 @@ android-app/
│ │ │ │ │ ├── NotificationsRepository.kt │ │ │ │ │ ├── NotificationsRepository.kt
│ │ │ │ │ ├── PlayerShardRepository.kt │ │ │ │ │ ├── PlayerShardRepository.kt
│ │ │ │ │ ├── SettingsRepository.kt │ │ │ │ │ ├── SettingsRepository.kt
│ │ │ │ │ ├── ShardFeaturesRepository.kt
│ │ │ │ │ ├── ShardRepository.kt │ │ │ │ │ ├── ShardRepository.kt
│ │ │ │ │ └── WikiRepository.kt │ │ │ │ │ └── WikiRepository.kt
│ │ │ │ ├── di/ │ │ │ │ ├── di/
@@ -171,6 +173,8 @@ android-app/
│ │ │ │ │ ├── session/ │ │ │ │ │ ├── session/
│ │ │ │ │ │ └── SessionViewModel.kt │ │ │ │ │ │ └── SessionViewModel.kt
│ │ │ │ │ ├── shard/ │ │ │ │ │ ├── shard/
│ │ │ │ │ │ ├── AtlasScreen.kt
│ │ │ │ │ │ ├── AtlasViewModel.kt
│ │ │ │ │ │ ├── ChampsScreen.kt │ │ │ │ │ │ ├── ChampsScreen.kt
│ │ │ │ │ │ ├── ChampsViewModel.kt │ │ │ │ │ │ ├── ChampsViewModel.kt
│ │ │ │ │ │ ├── FrameFields.kt │ │ │ │ │ │ ├── FrameFields.kt
@@ -180,7 +184,13 @@ android-app/
│ │ │ │ │ │ ├── GuildsViewModel.kt │ │ │ │ │ │ ├── GuildsViewModel.kt
│ │ │ │ │ │ ├── HousesScreen.kt │ │ │ │ │ │ ├── HousesScreen.kt
│ │ │ │ │ │ ├── HousesViewModel.kt │ │ │ │ │ │ ├── HousesViewModel.kt
│ │ │ │ │ │ ├── LeaderboardsScreen.kt
│ │ │ │ │ │ ├── LeaderboardsViewModel.kt
│ │ │ │ │ │ ├── LiveBoard.kt │ │ │ │ │ │ ├── LiveBoard.kt
│ │ │ │ │ │ ├── MarketScreen.kt
│ │ │ │ │ │ ├── MarketViewModel.kt
│ │ │ │ │ │ ├── RulesScreen.kt
│ │ │ │ │ │ ├── RulesViewModel.kt
│ │ │ │ │ │ ├── ShardComponents.kt │ │ │ │ │ │ ├── ShardComponents.kt
│ │ │ │ │ │ ├── ShardEventText.kt │ │ │ │ │ │ ├── ShardEventText.kt
│ │ │ │ │ │ ├── ShardScreen.kt │ │ │ │ │ │ ├── ShardScreen.kt
@@ -284,6 +294,7 @@ android-app/
│ │ │ │ │ ├── PlayerShardDtoTest.kt │ │ │ │ │ ├── PlayerShardDtoTest.kt
│ │ │ │ │ ├── PublicDtoTest.kt │ │ │ │ │ ├── PublicDtoTest.kt
│ │ │ │ │ ├── ShardBoardDtoTest.kt │ │ │ │ │ ├── ShardBoardDtoTest.kt
│ │ │ │ │ ├── ShardContentDtoTest.kt
│ │ │ │ │ ├── ShardDtoTest.kt │ │ │ │ │ ├── ShardDtoTest.kt
│ │ │ │ │ ├── SsoDtoTest.kt │ │ │ │ │ ├── SsoDtoTest.kt
│ │ │ │ │ └── WikiDtoTest.kt │ │ │ │ │ └── WikiDtoTest.kt
@@ -294,7 +305,8 @@ android-app/
│ │ │ │ └── FakeShardStream.kt │ │ │ │ └── FakeShardStream.kt
│ │ │ └── repository/ │ │ │ └── repository/
│ │ │ ├── AccountTrustedDevicesTest.kt │ │ │ ├── AccountTrustedDevicesTest.kt
│ │ │ ── ConnectionVersionGuardTest.kt │ │ │ ── ConnectionVersionGuardTest.kt
│ │ │ └── ShardFeaturesRepositoryTest.kt
│ │ ├── ui/ │ │ ├── ui/
│ │ │ ├── admin/ │ │ │ ├── admin/
│ │ │ │ ├── AdminContentViewModelTest.kt │ │ │ │ ├── AdminContentViewModelTest.kt
@@ -304,7 +316,8 @@ android-app/
│ │ │ ├── contact/ │ │ │ ├── contact/
│ │ │ │ └── ContactViewModelTest.kt │ │ │ │ └── ContactViewModelTest.kt
│ │ │ ├── navigation/ │ │ │ ├── navigation/
│ │ │ │ ── MenuAccessTest.kt │ │ │ │ ── MenuAccessTest.kt
│ │ │ │ └── MenuFeatureGatingTest.kt
│ │ │ ├── notifications/ │ │ │ ├── notifications/
│ │ │ │ └── NotificationRoutingTest.kt │ │ │ │ └── NotificationRoutingTest.kt
│ │ │ ├── player/ │ │ │ ├── player/
@@ -315,6 +328,8 @@ android-app/
│ │ │ │ ├── FrameFieldsTest.kt │ │ │ │ ├── FrameFieldsTest.kt
│ │ │ │ ├── LiveBoardTest.kt │ │ │ │ ├── LiveBoardTest.kt
│ │ │ │ ├── ShardBoardViewModelTest.kt │ │ │ │ ├── ShardBoardViewModelTest.kt
│ │ │ │ ├── ShardContentHelpersTest.kt
│ │ │ │ ├── ShardContentViewModelTest.kt
│ │ │ │ └── ShardEventTextTest.kt │ │ │ │ └── ShardEventTextTest.kt
│ │ │ ├── theme/ │ │ │ ├── theme/
│ │ │ │ └── BrandColorTest.kt │ │ │ │ └── BrandColorTest.kt

502
android/THEMING_AND_NAV.md Normal file
View File

@@ -0,0 +1,502 @@
# Android: honoring admin-configurable theming & navigation
> Build contract for the Android client's half of the feature shipped in
> [`docs/website/THEMING_AND_NAV.md`](../website/THEMING_AND_NAV.md).
> Same workflow as the website side: design → phased build → verify.
> Milestone **M12**; see [`PLAN.md`](./PLAN.md) §9.
## 1. Goal
The website merged runtime admin theming, brand assets and navigation overrides
to `main` (website#126 / docs#109). An admin who re-skins the site from
Admin → Appearance and restructures the header from Admin → Navigation currently
sees **none of it on the phone**: the app reads exactly one field, `brand.accent`,
and renders a hardcoded `APP_MENU`.
This milestone makes the app a full consumer of that contract:
1. **Theme** — the whole resolved color palette, the corner-radius scale, the
shadow depth, and the font choice.
2. **Brand assets** — the uploaded logo and hero, which the app has modeled in
`BrandDto` since M1 and has never rendered.
3. **Navigation** — the public header's labels, order, hidden entries, dropdown
sections and admin-added links, plus the label/hidden overrides for the
player and staff surfaces.
## 2. Core principle: the shipped app is the default, always
The website's governing invariant is that an instance with no settings rows
renders byte-for-byte as it did before the feature existed. **The app inherits
that invariant unchanged**, and it is unusually cheap to honor here because of a
fact worth stating plainly:
> **The app's `ui/theme/Color.kt` palette is already, value for value, the
> `runic-gateway` preset.** All fifteen themable tokens match. The M5 design pass
> was drawn from the same `theme.css` the preset was later extracted from.
So the fallback for every color is not a "close enough" approximation — it is the
identical value. A shard with no `theme_visual` row must produce a `ColorScheme`
that is `==` to today's `ShardColorScheme`, and that is a testable claim, not an
aspiration. It is locked by a test (§7, AC-1).
The same asymmetry the server uses applies on the client: **forgiving on read.**
A token that is missing, malformed, or unknown falls back field-by-field to the
shipped value. A bad `--accent` must not discard a good `--bg` beside it, and a
settings call that fails is the same state as "no overrides" — never an error
screen, never a half-painted theme.
## 3. What the server already publishes
No backend work. Everything below is live on `website/main` today.
| Source | Field | Shape |
|---|---|---|
| `GET /public/settings` | `theme` | `Record<cssVar, string>` — 15 colors, 4 radii, `--shadow-card`, 3 font stacks. **Absent** when no row exists |
| `GET /public/settings` | `brand.accent` / `.logo` / `.hero` / `.favicon` | Already **effective** values (override → env). The app reads `accent` today |
| `GET /public/settings` | `nav_public` | Raw JSON **string**: a bare items map, or `{items, sections, links}` |
| `GET /api/v1/settings/nav` | `nav_admin`, `nav_player` | Raw JSON strings. Gate is `requireAuth`, **no role check** — a player may read it |
Two shapes to get right on the wire:
- `nav_public` is a **JSON string inside a JSON object**, because `settings.value`
is `TEXT`. It is parsed a second time, exactly as the web client's
`parseJsonSetting` does.
- `theme` being **absent** and `theme` being `{}` are the same thing to the app,
and both mean "shipped defaults". The server never emits an empty map
(`resolveThemeTokens` returns `null` instead), but the app must not depend on
that.
**The trap in this payload: read the resolved fields, never the raw rows.**
`theme_visual` and `brand_assets` are in `PUBLIC_KEYS`, so their raw JSON strings
ride along in the same response as `theme` and `brand`. They are *inputs* — a
preset id and a sparse custom overlay — and re-deriving a palette from them would
be a second implementation of `resolveThemeTokens`, in Kotlin, guaranteed to
drift the first time a preset changes. The app consumes `theme` and `brand`,
which the server has already layered `:root ← preset ← custom` for it, and models
neither raw key. `nav_public` is the one raw row the app does read, because there
is no resolved counterpart — the merge is the *client's* job on the web too.
## 4. Locked decisions
| # | Decision |
|---|---|
| Theme depth | **Colors, radii, shadow and fonts** — the full token set, not accent-only |
| Fonts | **Bundle the families**, do not use downloadable fonts — see §5.3 |
| Radii | Applied as a **ratio against the `runic-gateway` baseline**, not as literal dp — see §5.2 |
| Semantic color | `--mode-live` / `--mode-maint` and the app's success/warning/danger pills stay **fixed**, never themed. Mirrors the server's `FIXED_TOKENS` |
| Light mode | Still **out of scope**. Every v1 preset is dark; the website's Parchment preset was cancelled (website §8 phase 9). The app stays dark-only, and `Theme.kt` keeps its single `darkColorScheme` |
| Favicon | **No app surface.** Ignored, and not modeled |
| Added links | A path matching a known app route opens the **native screen**; anything else hands off to a **Custom Tab** — see §6.3 |
| `nav_admin` / `nav_player` | **`label` and `hidden` only.** No order, no group — see §6.4 |
| Refresh | On connect, on **process start**, and on **resume** alongside the existing role re-validation — see §5.5 |
| Failure posture | Forgiving on read, field by field. A failed settings call renders the shipped app, never an error |
## 5. Theme
### 5.1 Colors — the 15-token map
Every themable token has exactly one home in the app palette. This table is the
contract; `ui/theme/Color.kt`'s current constants are its right-hand column.
| CSS token | App constant | Shipped value | Material role(s) |
|---|---|---|---|
| `--bg` | `ShardSurface` | `#0E1318` | `surface`, `surfaceContainerLow` |
| `--bg-deep` | `ShardPage` | `#0B0F14` | `background` |
| `--panel-a` | `ShardCardTop` | `#192231` | feature-card gradient top |
| `--panel-b` | `ShardCardBottom` | `#141A21` | feature-card gradient bottom |
| `--panel-flat` | `ShardElevated` | `#11161D` | `surfaceVariant`, `surfaceContainer`, `surfaceContainerHigh` |
| `--line` | `ShardOutline` | `#2A3544` | `outline` |
| `--line-soft` | `ShardDivider` | `#1D2733` | `outlineVariant` |
| `--accent` | `ShardAccent` | `#7F99BD` | `secondary`, `tertiary` |
| `--accent-bright` | `ShardCta` | `#CDD9E8` | `primary`, `onSecondaryContainer` |
| `--ink` | `ShardHeading` | `#EEF3F8` | brightest headings |
| `--head` | `ShardHeadingDim` | `#E6EDF6` | heading on surface |
| `--text` | `ShardBody` | `#C4CDD8` | `onBackground`, `onSurface` |
| `--muted` | `ShardMuted` | `#AEB8C4` | `onSurfaceVariant` |
| `--dim` | `ShardFaint` | `#6F7D8E` | meta / faint labels |
| `--blue` | `ShardPillBg` | `#13243C` | `secondaryContainer` |
`ShardOnCta` (`#0B0F14`) is **derived**, not themed: it is text drawn on the
`--accent-bright` fill, and it tracks `--bg-deep`. This mirrors the server's
derived-token rule for `--panel-grad` — a value expressed in terms of another
token must never be frozen as a literal, or a future light preset inherits a dark
one and looks broken.
**Two consumers, one resolution.** Ten of these fifteen have a Material role;
five do not — `ShardCardTop`, `ShardCardBottom`, `ShardHeading`, `ShardHeadingDim`
and `ShardFaint`. So the resolved palette is a single `ShardPalette` data class,
provided two ways:
- fed into `darkColorScheme(...)` for the Material roles;
- exposed as a `LocalShardPalette` CompositionLocal for the rest.
Today those non-Material colors are imported as top-level `val`s straight from
`Color.kt`. That surface was measured before scoping the phase, and it is
**smaller than it looks** — two files, sixteen imports:
| file | imports | themable | semantic (stay fixed) |
|---|---|---|---|
| `ui/components/ThemeComponents.kt` | 14 | `ShardCardTop`, `ShardCardBottom`, `ShardElevated`, `ShardFaint`, `ShardOutline`, `ShardPillBg`, `ShardPillFg` | the 7 success/warning/danger constants |
| `ui/shard/ShardComponents.kt` | 2 | — | `ShardSuccess`, `ShardSuccessDot` |
So the migration is **one file and seven constants**; nothing else in the app
reaches past `MaterialTheme.colorScheme`. That is the M5 design's premise paying
off — the ~20 screens take the new palette through the `ColorScheme` swap with no
per-screen work, which is exactly why this milestone is affordable.
It is still the phase's correctness risk rather than its bulk: leaving a direct
`ShardCardTop` import behind is a card that stays blue on a Fantasy shard, and
nothing fails to compile. A grep for `ui.theme.Shard` imports outside
`ui/theme/` — expected to return only the semantic constants once phase 1
lands — is the cheap check, and belongs in the phase's PR description.
**Accent handling changes.** `RunicGatewayTheme(accent: Color?)` currently copies
one color onto `primary`/`secondary`/`tertiary`. That was a reasonable stand-in
for a one-field contract and is now wrong twice over: it puts `--accent` on
`primary`, which the table above assigns to `--accent-bright`, and it ignores the
other fourteen. It is replaced by `RunicGatewayTheme(appearance: SiteAppearance)`.
`brand.accent` remains the fallback for `--accent` when `theme` is absent but the
env accent is set — which is exactly the pre-feature branding path, and must keep
working.
### 5.2 Radii — a ratio, not a literal
The app's `Shapes` came from the M5 mockup, not from `theme.css`, and the two
scales genuinely differ:
| | web | app |
|---|---|---|
| input / chip | `--radius-input` 8px | `extraSmall`/`small` 8dp |
| card | `--radius-card` 10px | `medium` **12**dp |
| panel | `--radius-panel` 12px | `large` **16**dp |
| — | — | `extraLarge` 24dp |
| pill | `--radius-pill` 999px | `CircleShape` at call sites |
A literal mapping would restyle the untouched app the moment this milestone
ships — `medium` 12→10, `large` 16→12 — which §2 forbids. Copying the app's
scale into the server is worse: a second source of truth.
**Decision: apply the radii as a ratio.** For each of the four fields compute
`resolved ÷ runic-gateway baseline`, then scale the app's own shipped dp value by
it. Consequences, all of them wanted:
- an untouched instance, or one that explicitly picks `runic-gateway`, gives
ratio `1.0` for all four and is a **provable no-op**;
- Fantasy (`--radius-panel: 3px`) → ratio `0.25``large` 16dp → 4dp: sharp
corners, at the app's own scale;
- Modern (8px) → `0.667` → 12dp;
- `extraLarge` has no web counterpart and follows `--radius-panel`'s ratio, since
it is the panel family;
- `--radius-pill` at 999 keeps `CircleShape`; below ~50% of baseline it resolves
to a rounded rect, so an admin who squares the site off squares off the app's
chips too.
Round to whole dp and clamp at 0.
### 5.3 Fonts — bundled, mapped by first family
The shortlist is 12 options across three roles, spanning **eight** families:
- **serif** — EB Garamond, Merriweather, Playfair Display, IM Fell English, Georgia*
- **display** — Cinzel, Playfair Display, EB Garamond, IM Fell English
- **sans** — Inter, Work Sans, Source Sans 3, Helvetica Neue / Arial*
\* system stacks with no webfont; on Android these resolve to the platform
`FontFamily.Serif` / `FontFamily.SansSerif`, which is what the app uses today.
**Cinzel is already bundled** (`res/font/cinzel_variable.ttf`, M5). Seven more
are added: EB Garamond, Merriweather, Playfair Display, IM Fell English, Inter,
Work Sans, Source Sans 3. All SIL OFL; each needs its license file under
`app/licenses/`, **not** under `res/font/` (aapt rejects a `.txt` there — the M5
gotcha).
Downloadable fonts were rejected: they need the Play Store font provider, so a
de-Googled device silently falls back, and every text style gains an async
loading state.
Resolution is by **the first family name in the stack**, which is how the value
is constructed server-side and the only part that carries the choice:
```
"'EB Garamond', Georgia, serif" → EBGaramond
"Cinzel, Georgia, serif" → Cinzel
"Georgia, \"Times New Roman\", serif" → FontFamily.Serif (system)
"\"Helvetica Neue\", Arial, sans-serif" → FontFamily.SansSerif (system)
<anything unrecognized> → the role's shipped family
```
The three roles map onto `Type.kt`'s existing three groups verbatim:
`--display` → the Cinzel display/headline/title block, `--serif` → the `AppSerif`
body block, `--sans` → the `AppSans` label block. Sizes, weights and tracking do
not move — only the family.
**IM Fell English has no bold weight** (the website doc records the same). A
`FontWeight.Bold` request against it must resolve to its single weight rather
than synthesize; check what Compose does here on device and pin the behavior in
the phase's notes.
APK cost: roughly **1.52.5 MB** across seven families, variable-axis where Google
Fonts publishes one (Cinzel, EB Garamond, Merriweather, Playfair Display, Inter,
Work Sans, Source Sans 3) and single-weight for IM Fell English. Measure the
release APK before and after, and record both numbers in the PR — R8 does not
shrink `res/font/`.
### 5.4 Shadow depth
`--shadow-card` is one of four closed values. Compose has no CSS box-shadow, so
it maps to card elevation:
| stored value | elevation |
|---|---|
| `none` | 0dp |
| `0 8px 20px rgba(0,0,0,0.25)` (Soft) | 2dp |
| `0 14px 34px rgba(0,0,0,0.3)` (Default) | 4dp |
| `0 18px 44px rgba(0,0,0,0.45)` (Deep) | 8dp |
Matched by exact string against the server's `SHADOW_OPTIONS`; anything else is
the shipped default. Applied to `FeatureCard` and the Material `Card` defaults.
### 5.5 When the appearance is (re-)read
Today `AppViewModel.loadBrand()` calls `GET /public/settings` **once**, on
process start or on connect, and holds a `BrandDto`. That becomes a
`SiteAppearance``{brand, theme, navPublic}` — held in the same place and
refreshed:
- **on connect** and **on process start** (as today);
- **on resume**, beside the existing `sessionViewModel.revalidate()`. An admin
changing the theme on a laptop and picking the phone up should see it, and the
app already pays for a resume round-trip.
The authenticated `GET /api/v1/settings/nav` is fetched only when the session is
signed in, and re-fetched when the session changes — the same lifecycle
`ShardFeaturesRepository` already uses (M11). Signing out drops the cached admin
and player overrides.
Every one of these is best-effort. A failed refresh keeps the last good
appearance; there is no loading state and no error surface.
### 5.6 Brand assets — the logo and the hero
`brand.logo` and `brand.hero` have been in `BrandDto` since M1 and have **never
been rendered**; the app draws `brand.name` as text everywhere the website draws
a logo. Nothing new is needed to fetch them — they already arrive resolved, and
`AppViewModel.resolveAsset` / `LocalAssetResolver` already turn a site-relative
`/uploads/…` path into an absolute URL. Coil is already a dependency.
Two surfaces, chosen to mirror the website's without inventing new layout:
- **the drawer header**, above the instance name that sits there today;
- **the top bar**, replacing the uppercased name when a logo exists.
And the hero on **Home**, above the title block, which is the one screen with a
hero-shaped space.
The M5 `BrandLogo` rule carries over: **render nothing when the slot is empty.**
Not a placeholder, not a reserved gap — an instance with no uploaded logo must
lay out exactly as it does today, which is §2 applied to assets. On the centered
surfaces the website stacks the logo *above* rather than beside, for the same
reason: a row would change the block's height on instances that have no logo.
An asset that fails to load is the same as no asset. No broken-image icon, no
retry.
## 6. Navigation
### 6.1 The hard constraint carries over
The website's §7 constraint is a security boundary and it survives verbatim here,
with one clarification the app makes concrete: **an override is presentation.**
The app's two gates — `MenuAccess` against the session, and `MenuEntry.feature`
against `GET /public/shard/features` (M11) — run **after** the override merge and
are unchanged by it. An override cannot introduce an app route, cannot touch
`access` or `feature`, and `hidden: false` never un-hides an entry the caller's
role or the shard's visibility config would otherwise withhold. Hiding is
subtractive, exactly as `applyNavOverrides` has it.
### 6.2 Path → app route
The public nav is keyed by **website** paths. The app needs a mapping table, and
it is the one new piece of cross-repo coupling this milestone introduces — so it
lives in one file with the website's `NAV` array quoted beside it.
| website `to` | app route | note |
|---|---|---|
| `/` | `Routes.HOME` | |
| `/site/news` | `Routes.NEWS` | |
| `/site/five-on-friday` | `Routes.news(FIVE_ON_FRIDAY)` | the app's News screen already has all four categories as tabs — these three select one |
| `/site/newsletter` | `Routes.news(NEWSLETTER)` | |
| `/site/screenshots` | `Routes.news(SCREENSHOTS)` | |
| `/wiki` | `Routes.WIKI` | |
| `/site/shard` | `Routes.SHARD` | `feature: status` |
| `/site/champs` | `Routes.SHARD_CHAMPS` | **not in `APP_MENU` today** — reached via the Shard hub |
| `/site/guilds` | `Routes.SHARD_GUILDS` | as above |
| `/site/governors` | `Routes.SHARD_GOVERNORS` | as above |
| `/site/houses` | `Routes.SHARD_HOUSES` | as above |
| `/site/rules` | `Routes.SHARD_RULES` | |
| `/site/atlas` | `Routes.ATLAS` | |
| `/site/leaderboards` | `Routes.SHARD_LEADERBOARDS` | |
| `/site/market` | `Routes.SHARD_MARKET` | |
| `/site/about` | `Routes.page("about")` | |
Three asymmetries to resolve rather than paper over:
- **`Routes.NEWS` takes no category argument today.** It gains an optional one so
the three category entries can land on the right tab. This is a small route
change with its own test, not a nav concern.
- **Four web entries have no `APP_MENU` row** (champs / guilds / governors /
houses — the app puts them behind the Shard hub, which is the better phone
shape and stays). An override for one of them therefore has a mapped route but
no menu entry. **Rule: an override for a path the app does not surface in its
menu is ignored**, exactly as the web drops an override for an unknown `to`.
It is *not* an invitation to add the entry — the hub is a deliberate design
choice, and a nav override may not introduce navigation.
- **Ten app entries have no `nav_public` counterpart** — Contact, Account and
Notifications, the three player groups, and the four staff rows. They are
unaffected by `nav_public` and keep their coded order, appended after the
overridden public block in the drawer. (They already sit below the public
entries today, so this is the current layout, not a new one.) A few of them are
instead reachable through `nav_admin` / `nav_player` — but fewer than you would
expect, which is §6.4's subject.
### 6.3 Sections and added links
`nav_public` may carry `sections` and `links` (website phase 10). Both land in
the drawer:
- **A section** renders as a drawer group with its label as a header and its
members indented beneath — the drawer's natural idiom. The website's
click-to-open dropdown does not translate and is not copied; a drawer is
already a vertical list.
- **`pruneNav`'s rule is ported and is load-bearing**: a section whose every
member is hidden by the role or feature gate must not render as an empty
header. The app's port drops it.
- **An added link** carries no gate and always shows, matching the web. Its `to`
is validated the same way the web validates it on read — must start with a
single `/`, no `//`, no whitespace or quote characters — and a value failing
that is dropped rather than rendered.
An added link **opens natively when its path maps to an app route**, and hands
off to a Custom Tab otherwise. The patterns the app can resolve:
```
/ → HOME
/site/news|five-on-friday|newsletter|screenshots → NEWS (category)
/site/news/<idOrSlug> → POST
/wiki → WIKI
/wiki/<slug> → WIKI_PAGE
/site/<shard surface> → the mapped shard route (per §6.2)
/site/about, /page/<slug> → PAGE
/contact → CONTACT
anything else → WebHandoff (Custom Tab), M3's existing hand-off
```
A native match still passes through the app's own gates: an added link to
`/site/market` on a shard that does not publish the market lands on the Market
screen's honest "not published here" state (M11's `FEATURE_UNAVAILABLE`), which
is what typing the URL on the web does too. The link itself is not gated — that
is the website's decision and the app does not second-guess it.
### 6.4 `nav_admin` and `nav_player` — label and hidden only
Both are bare maps and neither carries sections or links. The app honors
**`label` and `hidden`, and ignores `order` and `group`.**
The reason is that the app's rows are a small and *differently shaped* subset —
and the actual overlap was measured before scoping the phase, because it turned
out to be thinner than the milestone assumed:
| website `to` | app route | |
|---|---|---|
| `/player` | `PLAYER_CHARACTERS` | ✅ |
| `/account` | `ACCOUNT` | ✅ |
| `/account/appeals` | — | the app has no appeals screen at all |
| `/admin` | `ADMIN_DASHBOARD` | ✅ |
| `/admin/moderation` | `ADMIN_MODERATION` | ✅ |
| — | `ADMIN_CONTENT` | an app-side aggregate of the website's separate Posts / Pages / Wiki / Activity rows |
| — | `ADMIN_SUPPORT` | likewise; the nearest web row is `/admin/moderation/appeals`, which is not the same screen |
| — | `PLAYER_VENDORS`, `PLAYER_HOUSES` | no player-portal row on the web |
| — | `NOTIFICATIONS`, `CONTACT` | app-only surfaces |
**So `nav_player` reaches two app rows and `nav_admin` reaches two.** The website
sidebar's other ~18 rows are admin *configuration* the app deliberately excludes
(M10/M11), and two of the app's four staff entries are aggregates with no single
web row to be renamed from.
That is the whole case for label-and-hidden-only. Reordering two rows against a
foreign order of twenty-two is noise, and `group` names sections the app does not
render. Renaming "Characters" or hiding it is still a real intent that should
reach the phone, and four rows' worth of it is worth one cached call.
**It is also the case for questioning whether phase 7 is worth building at all.**
Four rows is a thin return for a new authenticated fetch, a session-keyed cache
and its teardown. It is scheduled last precisely so that decision can be taken
with the rest of the milestone already working — dropping it costs nothing that
phases 06 depend on. Unmapped keys are ignored either way.
**A label override replaces a `@StringRes`.** `MenuEntry.labelRes` is an int; the
resolved entry carries `label: String?` beside it and the drawer prefers it. That
means an admin's label is **not localized** — it is one string for every locale,
which is what an admin typing a label means, and matches the website.
## 7. Acceptance criteria
- **AC-1 — the no-op proof.** With `theme` absent, `nav_public` absent and no
`brand_assets`, the resolved `ColorScheme`, `Shapes`, `Typography` and drawer
entry list are **equal** to today's shipped values. A unit test asserts the
full `ColorScheme` equality, not a spot check.
- **AC-2 — per-field fallback.** A `theme` map carrying one valid token and four
malformed ones applies the one and falls back on the four.
- **AC-3 — the gates still hold.** An override marking a feature-gated or
role-gated entry `hidden: false` shows nothing to a caller who fails that gate.
A section whose members are all gated out does not render.
- **AC-4 — degradation.** With the settings call failing, the app renders the
shipped theme and the coded menu, with no error surface.
- **AC-5 — on-device.** Two passes on the AVD against a local website:
- one against an instance themed **Fantasy**, with a reordered and sectioned
nav, one added link of each kind (native-mapped and Custom-Tab), and an
uploaded logo and hero;
- one against an **untouched** instance, confirming AC-1 by eye as well as by
test — this is the pass that catches a token a screen never read.
The role dimension reuses the existing five-rung walk (`anonymous`,
`logged_in`, `player`, `staff`, `admin`) from [`../link/v3.md`](../link/v3.md)
§11, since §6.1's whole claim is that the override merge does not disturb the
gates.
## 8. Build phases
Every phase targets **`edge`** in `Android-app/` and `docs/`, cut fresh from
`main` in both. The feature reaches `main` as **one `edge` → `main` merge** when
all phases are done — the same shape the website side used. Do not open a phase
PR against `main`.
| # | Phase | Ships |
|---|---|---|
| **0** | **Contract & appearance store** | `SettingsDto` gains `theme: Map<String,String>?` and `nav_public: String?`; `SiteAppearance` replaces the bare `BrandDto` in `AppViewModel`; second-stage JSON parse; resume refresh (§5.5). **No visual change** — this phase must be invisible |
| **1** | **Colors** | `ShardPalette` + `LocalShardPalette`; all direct `Color.kt` imports migrated; `RunicGatewayTheme(appearance)`; AC-1 + AC-2 tests |
| **2** | **Radii & shadow** | Ratio-scaled `Shapes` (§5.2), elevation map (§5.4) |
| **3** | **Fonts** | Seven bundled families + licenses; stack → `FontFamily` resolution; `Type.kt` takes its three families from the resolved theme. APK size recorded |
| **4** | **Brand assets** | Logo in the drawer header and top bar, hero on Home (§5.6). Coil + `LocalAssetResolver` already exist; renders nothing when unset |
| **5** | **Public nav: label / order / hidden** | The path→route table (§6.2), `Routes.news(category)`, the merge, drawer wiring. AC-3 |
| **6** | **Public nav: sections & added links** | Drawer groups, `pruneNav` port, link path validation, native-route resolution + Custom Tab fallback (§6.3) |
| **7** | **Authenticated navs** | `GET /api/v1/settings/nav` behind a session-keyed repository; label/hidden for the four mapped rows (§6.4). **Optional — reconsider before starting it** |
| **8** | **Docs, coverage & cutover** | This doc's "as landed" notes and any amendments the build forces, the `PLAN.md` §9 M12 entry refreshed, Sonar coverage for the new modules, AC-5 on-device walk, then `edge``main` |
Phase 0 is the one with a hard rule attached: **it must change nothing on
screen.** Everything after it is additive on top of a store that is already
proven not to have moved anything.
## 9. Out of scope
- **Light mode / a light preset.** The website cancelled its Parchment phase; the
app stays dark-only.
- **Favicon.** No app surface.
- **Editing any of this from the app.** The M10 staff surface does not include
Appearance or Navigation, and this milestone does not add them. The app is a
consumer.
- **A theme preview.** Out of scope on the web too.
- **Per-screen restyling.** If a screen looks wrong under a warm preset, that is
a token the screen should have been reading and did not — fix the call site,
do not add a special case.

904
installer/INSTALL.md Normal file
View File

@@ -0,0 +1,904 @@
# 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 |

1383
installer/PLAN.md Normal file

File diff suppressed because it is too large Load Diff

67
installer/PROJECT_TREE.md Normal file
View File

@@ -0,0 +1,67 @@
# 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,7 +23,31 @@ Every route **except `GET /health`** requires the shared token from `sidecar.tom
| REST | `X-Api-Key: <token>` | | REST | `X-Api-Key: <token>` |
| WebSocket | `?token=<token>` in the connect URL (browsers can't set headers on a WS handshake) | | 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 (the sidecar logs it); rotate by editing `sidecar.toml` and restarting. 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`.
--- ---
@@ -31,31 +55,31 @@ Missing or wrong token → **401** `{"error":"missing or invalid auth token"}`.
The wire protocol is versioned so a mismatch is caught immediately instead of failing weirdly. The wire protocol is versioned so a mismatch is caught immediately instead of failing weirdly.
- Every response carries an **`X-UOLink-Version: 2`** header. - Every response carries an **`X-UOLink-Version: 3`** header.
- `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 2`. - `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 3`.
- **Optionally**, send `X-UOLink-Version: 2` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**: - **Optionally**, send `X-UOLink-Version: 3` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**:
```json ```json
{ "error": "protocol version mismatch", "sidecar_protocol": 2, "client_protocol": "1" } { "error": "protocol version mismatch", "sidecar_protocol": 3, "client_protocol": "2" }
``` ```
Pin the version you built against and compare it to the header (or `/health.protocol`) at startup. Pin the version you built against and compare it to the header (or `/health.protocol`) at startup.
**v2 (Protocol 2.0)** added the account-provisioning surface (§6.x: `POST /accounts/create`, `DELETE /link/{account}`) and the `account.*` events. Outbound event kinds are **additive** — a v1 client that ignores unknown kinds keeps working against the live feed — but the new *endpoints* require a v2 sidecar. If you send `X-UOLink-Version: 1`, calls to the new endpoints are refused with the 409 above. **v2 (Protocol 2.0)** added the account-provisioning surface (§6.x: `POST /accounts/create`, `DELETE /link/{account}`) and the `account.*` events. Outbound event kinds are **additive** — a v1 client that ignores unknown kinds keeps working against the live feed — but the new *endpoints* require a v2 sidecar. If you send `X-UOLink-Version: 1`, calls to the new endpoints are refused with the 409 above.
**v3 (Protocol 3.0) is being built and the version has not been bumped yet.** It is defined as *adds **v3 (Protocol 3.0)** adds `world.ruleset`, `points.board` and `vendor.listing` /
`world.ruleset`, `points.board`, `vendor.listing` / `vendor.listing.remove`*, and the bump to `vendor.listing.remove`, with the `GET /ruleset`, `/points` and `/market` reads that serve them from
`X-UOLink-Version: 3` happens **exactly once**, at the end, when [`v3.md`](v3.md) §4's `edge` → `main` the sidecar's store. Same shape as the v2 bump: the event kinds are additive, so a v2 client that
cutover lands — because a bump is an operator-visible hard break (409 on every protected route, and ignores unknown kinds keeps working against the live feed, but the three new endpoints require a v3
the website's WS closes on the `ws.hello` mismatch), so doing it per phase would break the site sidecar. There is deliberately **no feature-negotiation array** — v3 implies all three kinds, so the
repeatedly. version number alone tells you what is available.
Until then, sidecars on `edge` still report `2` while already carrying some v3 kinds and endpoints. **Upgrading a v2 integration.** The bump is an operator-visible hard break in one direction only: a
That is safe in the direction that matters: event kinds are additive, and a client that ignores client still declaring `2` gets a 409 on every protected route and, on the WebSocket, a closed
unknown kinds and tolerates a `404` on a not-yet-present endpoint keeps working. What you must **not** connection on the `ws.hello` mismatch. So update the pinned version at the same time you deploy the
do is infer feature availability from the version number during this window — probe the endpoint, or v3 sidecar. Nothing that existed in v2 changed shape, so that is the whole migration — the website
treat a missing `world.ruleset` as "this shard hasn't published one". There is deliberately **no does it with a one-shot boot migration of its `uo_link_config.protocol` row ([`v3.md`](v3.md) §4.1);
feature-negotiation array**: v3 implies all three kinds. a third-party client changes the constant it sends.
--- ---
@@ -68,7 +92,7 @@ GET /health (no auth)
```json ```json
{ {
"status": "ok", // "ok" when plugin connected AND db reachable, else "degraded" "status": "ok", // "ok" when plugin connected AND db reachable, else "degraded"
"protocol": 1, "protocol": 3,
"plugin_connected": true, // is the shard link up right now? "plugin_connected": true, // is the shard link up right now?
"database": "ok", // "ok" | "error" "database": "ok", // "ok" | "error"
"uptime": "3d 12h", "uptime": "3d 12h",
@@ -91,7 +115,7 @@ A push-only stream of game events as they happen. You do **not** send commands o
**On connect**, the first frame is: **On connect**, the first frame is:
```json ```json
{ "kind": "ws.hello", "protocol": 1 } { "kind": "ws.hello", "protocol": 3 }
``` ```
**Then** a continuous stream of event frames, each with at least `t` (epoch ms) and `kind`. Route on `kind`. **Then** a continuous stream of event frames, each with at least `t` (epoch ms) and `kind`. Route on `kind`.
@@ -955,7 +979,7 @@ sidecar defines no audiences. Deciding who may see what is the consuming site's
A typical character page: A typical character page:
```js ```js
const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "2" }; const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "3" };
// 1. render the roster // 1. render the roster
const roster = await fetch(`${BASE}/roster/${account}`, { headers: H }).then(r => r.json()); const roster = await fetch(`${BASE}/roster/${account}`, { headers: H }).then(r => r.json());

View File

@@ -347,6 +347,12 @@ of 25 vendors x 40 listings and **0.3 ms** in steady state (the per-vendor diff)
items / 43k mobiles. It is also the first stream to honour a per-player privacy toggle: ServUO's own items / 43k mobiles. It is also the first stream to honour a per-player privacy toggle: ServUO's own
`PlayerVendor.VendorSearch` flag, so a shop hidden in game is hidden on the site. `PlayerVendor.VendorSearch` flag, so a shop hidden in game is hidden on the site.
That completes 3.0's feature work, so the last step is the version itself: `PROTOCOL_VERSION` **2 →
3** and the coordinated `edge``main` merge across all four repos ([`v3.md`](v3.md) §4 and §4.1).
The bump is deliberately the *only* thing that happens at that moment — v3 adds kinds and endpoints
but changes nothing that already existed in v2 — so the operator-visible break is limited to
re-pinning the version, which the website does for itself in a one-shot boot migration.
### Config keys (`Config/Bridge.cfg`) ### Config keys (`Config/Bridge.cfg`)
```ini ```ini

View File

@@ -18,18 +18,23 @@ link/
│ ├── scripts/ │ ├── scripts/
│ │ └── gen_tree.py │ │ └── gen_tree.py
│ ├── workflows/ │ ├── workflows/
│ │ ├── pr-checks.yml
│ │ ├── release.yml │ │ ├── release.yml
│ │ ├── sonarqube.yml │ │ ├── sonarqube.yml
│ │ └── sync-project-tree.yml │ │ └── sync-project-tree.yml
│ └── PULL_REQUEST_TEMPLATE.md │ └── PULL_REQUEST_TEMPLATE.md
├── sidecar/ ├── sidecar/
│ ├── src/ │ ├── src/
│ │ ├── app.rs
│ │ ├── cli.rs
│ │ ├── config.rs │ │ ├── config.rs
│ │ ├── main.rs │ │ ├── main.rs
│ │ ├── rpc.rs │ │ ├── rpc.rs
│ │ ├── shard.rs │ │ ├── shard.rs
│ │ ├── store.rs │ │ ├── store.rs
│ │ ── web.rs │ │ ── unix.rs
│ │ ├── web.rs
│ │ └── windows.rs
│ ├── .gitignore │ ├── .gitignore
│ ├── Cargo.lock │ ├── Cargo.lock
│ ├── Cargo.toml │ ├── Cargo.toml

View File

@@ -1,5 +1,12 @@
# uo-link # 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. 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

@@ -1,6 +1,6 @@
# Protocol 3.0 — Shard content, standings & the visibility framework # Protocol 3.0 — Shard content, standings & the visibility framework
**Status:** In progress. All work lands on an `edge` branch in each repo; `edge``main` is the v3 cutover. **Status:** Feature-complete on `edge`; the cutover (order 6) is in review. All work lands on an `edge` branch in each repo; `edge``main` is the v3 cutover.
**Date:** 2026-07-28 **Date:** 2026-07-28
**Codebase:** ServUO 57.4, `<servuo>`, net48 / x64, Expansion **EJ**. **Codebase:** ServUO 57.4, `<servuo>`, net48 / x64, Expansion **EJ**.
**Companion to** [`PLAN.md`](PLAN.md) (1.0 read/event plane), [`PROTOCOL_2.md`](PROTOCOL_2.md) (2.0 provisioning + world-state streams), [`ADMIN_CONTROLS.md`](ADMIN_CONTROLS.md) (staff write plane), [`INTEGRATION.md`](INTEGRATION.md) (website API). **Companion to** [`PLAN.md`](PLAN.md) (1.0 read/event plane), [`PROTOCOL_2.md`](PROTOCOL_2.md) (2.0 provisioning + world-state streams), [`ADMIN_CONTROLS.md`](ADMIN_CONTROLS.md) (staff write plane), [`INTEGRATION.md`](INTEGRATION.md) (website API).
@@ -16,13 +16,18 @@ Each part is marked off here as it lands on `edge`. §9 carries the same state p
| 3 | **C** — spawn atlas (§6) | ✅ **Done** | website [#112](https://gitea.whitlocktech.com/RunicGateway/website/pulls/112) (parsers + CLI + tables) + [#113](https://gitea.whitlocktech.com/RunicGateway/website/pulls/113) (API + pages + admin panel), docs [#67](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/67) + [#68](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/68) | | 3 | **C** — spawn atlas (§6) | ✅ **Done** | website [#112](https://gitea.whitlocktech.com/RunicGateway/website/pulls/112) (parsers + CLI + tables) + [#113](https://gitea.whitlocktech.com/RunicGateway/website/pulls/113) (API + pages + admin panel), docs [#67](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/67) + [#68](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/68) |
| 4 | **B/2**`points.board` (§7) | ✅ **Done** | servuo-plugins [#4](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/4), link [#18](https://gitea.whitlocktech.com/RunicGateway/link/pulls/18), website [#114](https://gitea.whitlocktech.com/RunicGateway/website/pulls/114), docs [#69](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/69) | | 4 | **B/2**`points.board` (§7) | ✅ **Done** | servuo-plugins [#4](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/4), link [#18](https://gitea.whitlocktech.com/RunicGateway/link/pulls/18), website [#114](https://gitea.whitlocktech.com/RunicGateway/website/pulls/114), docs [#69](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/69) |
| 5a | **B/3 dependency** — cliloc table (§8.6) | ✅ **Done** | website [#115](https://gitea.whitlocktech.com/RunicGateway/website/pulls/115), docs [#70](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/70) | | 5a | **B/3 dependency** — cliloc table (§8.6) | ✅ **Done** | website [#115](https://gitea.whitlocktech.com/RunicGateway/website/pulls/115), docs [#70](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/70) |
| 5b | **B/3**`vendor.listing` (§8) | 🟨 In review | servuo-plugins [#5](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/5), link [#19](https://gitea.whitlocktech.com/RunicGateway/link/pulls/19), website [#116](https://gitea.whitlocktech.com/RunicGateway/website/pulls/116), docs [#71](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/71) | | 5b | **B/3**`vendor.listing` (§8) | **Done** | servuo-plugins [#5](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/5), link [#19](https://gitea.whitlocktech.com/RunicGateway/link/pulls/19), website [#116](https://gitea.whitlocktech.com/RunicGateway/website/pulls/116), docs [#71](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/71) |
| 6 | **Cutover**`PROTOCOL_VERSION` 2→3 (§4) | ⬜ Not started | — | | 6 | **Cutover**`PROTOCOL_VERSION` 2→3 (§4) | 🟨 In review | the bump: link [#20](https://gitea.whitlocktech.com/RunicGateway/link/pulls/20), website [#117](https://gitea.whitlocktech.com/RunicGateway/website/pulls/117), docs [#72](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/72) — then `edge``main`: servuo-plugins [#6](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/6), link [#21](https://gitea.whitlocktech.com/RunicGateway/link/pulls/21), website [#118](https://gitea.whitlocktech.com/RunicGateway/website/pulls/118), docs [#73](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/73) |
Order 5 split in two once §8.6's cliloc dependency turned out to be a client-format problem rather Order 5 split in two once §8.6's cliloc dependency turned out to be a client-format problem rather
than a parser (see §8.6). 5a is website-only and lands first so the marketplace ships with real item than a parser (see §8.6). 5a is website-only and lands first so the marketplace ships with real item
names; 5b is the four-repo wire change. names; 5b is the four-repo wire change.
**The `edge` → `main` half of order 6 is held for Android parity** (decided 2026-07-30, see §10): the
app sees none of the four new features and gates shard nav on session role alone, so merging the
cutover first would ship a shard whose app client silently disagrees with the web client about what is
public. The **bump** PRs into `edge` are unaffected and merge normally.
--- ---
## 1. Why 3.0 ## 1. Why 3.0
@@ -242,6 +247,39 @@ admin-set `uo_link_config.protocol` column — so it happens **exactly once**, a
from 2 to 3, so the cutover doesn't require a manual admin edit. `UOLINK_PROTOCOL` still overrides. from 2 to 3, so the cutover doesn't require a manual admin edit. `UOLINK_PROTOCOL` still overrides.
- No feature-negotiation array anywhere — v3 implies all three kinds. - No feature-negotiation array anywhere — v3 implies all three kinds.
### 4.1 What the bump actually touches
The version lives in five places, and all five move together:
| Where | Change |
|---|---|
| `link/sidecar/src/main.rs` | `PROTOCOL_VERSION` 2 → 3 (with the v3 note beside the v2 one), plus the sidecar README's worked example |
| `website/server/db/schema.sql` | `uo_link_config.protocol` column default 1 → 3, plus the boot migration below |
| `website/server/src/model/uoLinkConfig/uoLinkConfig.model.js` | `DEFAULT_PROTOCOL` — what a site with nothing saved yet declares |
| `website/server/src/utils/uoLinkClient.js` + `uoLinkSocket.js` | the `config.protocol || …` fallbacks, so an unset value can never quietly send `1` and 409 with a confusing message |
| `website/client/.../ShardAdmin.jsx`, `website/.env.example` | the admin form's initial value and the documented env default |
**The migration has to be one-shot, and that is the only subtle part.** `schema.sql` is re-run on
*every* boot (`utils/db.js::ensureSchema`), and every other statement in its migration block is an
idempotent `ADD COLUMN IF NOT EXISTS` / `MODIFY`. A bare `UPDATE uo_link_config SET protocol = 3`
would not be idempotent in the sense that matters: `protocol` is **admin-editable**, so an operator
who deliberately pins an older sidecar in Admin → Shard would silently be un-pinned on the next
restart. It is therefore gated on a marker row in `settings`:
```sql
ALTER TABLE uo_link_config MODIFY COLUMN protocol INT NOT NULL DEFAULT 3;
UPDATE uo_link_config SET protocol = 3
WHERE id = 1 AND protocol < 3
AND NOT EXISTS (SELECT 1 FROM settings WHERE `key` = 'uo_link_protocol_3_migrated');
INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_3_migrated', '1');
```
The marker is written *after* the `UPDATE`, so the first boot on the new build migrates and every
later boot is a no-op. A fresh install has no `uo_link_config` row to update and simply gets the
marker plus the new column default. `protocol < 3` rather than `= 2` so an install that never left
the old default of `1` is carried across too — it could not have been talking to a v2 sidecar
anyway.
--- ---
## 5. Part B/1 — `world.ruleset` ✅ Done ## 5. Part B/1 — `world.ruleset` ✅ Done
@@ -312,8 +350,11 @@ Sidecar — `store.rs`: singleton `ruleset(id CHECK(id=1), rev, json, updated_t)
`main.rs`: new arm in the board-projection match; `web.rs`: `GET /ruleset` served from the store, so `main.rs`: new arm in the board-projection match; `web.rs`: `GET /ruleset` served from the store, so
it answers during a shard outage (`PROTOCOL_2.md` §12.2). it answers during a shard outage (`PROTOCOL_2.md` §12.2).
Website — `uoLinkClient.getRuleset()`; `uoLinkSocket.backfill()` (object-shaped, so follow the Website — `uoLinkClient.getRuleset()`; `uoLinkSocket.backfill()` (object-shaped, so it cannot use the
`getPresence()` block's explicit form, not the array-only `snapshot()` helper); `shardIngest.js` array-only `snapshot()` helper — but it **must still go through `shardIngest.ingest()`**, as
`ingestEach` does, rather than calling `shardState.setRuleset` directly: the two arrival orders have
to produce the same stored frame, and a direct call quietly made backfill a second writer that
skipped the normalization below); `shardIngest.js`
`shardState.setRuleset`, **not** in `LOGGED_KINDS` (it re-arrives every reconnect and `server.hello` `shardState.setRuleset`, **not** in `LOGGED_KINDS` (it re-arrives every reconnect and `server.hello`
already marks those); `KIND_FEATURE['world.ruleset'] = 'ruleset'`; `shard_ruleset` singleton table already marks those); `KIND_FEATURE['world.ruleset'] = 'ruleset'`; `shard_ruleset` singleton table
(`rev`, `expansion`, `payload JSON`, `t`); `GET /public/shard/ruleset` behind (`rev`, `expansion`, `payload JSON`, `t`); `GET /public/shard/ruleset` behind
@@ -322,6 +363,17 @@ already marks those); `KIND_FEATURE['world.ruleset'] = 'ruleset'`; `shard_rulese
Client — NEW `routes/public/Rules.jsx` at `/site/rules`, alongside Client — NEW `routes/public/Rules.jsx` at `/site/rules`, alongside
`/site/champs|guilds|governors|houses`; live via `useShardFeed({ filter: new Set(['world.ruleset']) })`. `/site/champs|guilds|governors|houses`; live via `useShardFeed({ filter: new Set(['world.ruleset']) })`.
**The `shard` field falls back to the instance's own name.** ServUO ships `Server.cfg` with
`Name=My Shard`, so an operator who never edited it publishes that verbatim — which is the shard
saying *unnamed*, not naming anything, and the rules page then reads "My Shard" under a header
carrying the real one. `shardIngest` substitutes `settings.getInstanceName()` (the admin-editable
site title, else `BRAND_NAME` — the same resolution `getPublic().brand.name` uses, so one install
never shows two names) when `shard` is absent, blank, or exactly the stock default, matched
case-insensitively and trim-tolerantly but only as a **whole** value: a shard genuinely called
*"My Shard Reborn"* has named itself and keeps it. Applied at **ingest**, not on read, because the
ruleset is also broadcast live — the same object goes to the SSE fan-out, so a read-time
substitution would be undone by the next reconnect's frame.
### 5.4 Risk ### 5.4 Risk
Perf is nil (~3 KB per connect). The only real risk is publishing a secret, mitigated by the explicit Perf is nil (~3 KB per connect). The only real risk is publishing a secret, mitigated by the explicit
@@ -600,6 +652,15 @@ Client — NEW `routes/public/Leaderboards.jsx` at `/site/leaderboards`; a "Loya
added to `components/CharacterSheet.jsx`, one edit serving both `PlayerCharacter.jsx` and added to `components/CharacterSheet.jsx`, one edit serving both `PlayerCharacter.jsx` and
`AdminCharacter.jsx`. `AdminCharacter.jsx`.
**An unscored board still renders a row.** Most systems on a young shard have `top: []`, and a page
of blank cards reads as broken rather than as new — so a board with no entries shows a single
placeholder bearing the **instance's own name** with an em dash where a score goes, above the
existing "nobody has earned points here yet" line. It is deliberately **not** shaped like an entry —
no rank, no medal, no bar, muted — because a placeholder that looked like a real standing would be a
fabricated one; the first real entry replaces it outright. Purely presentational: the API keeps
sending an empty `top`, so no consumer ever receives an invented row. Web and app render it the same
way (`Leaderboards.jsx`, `LeaderboardsScreen.kt`).
### 7.5 What the run against a real shard changed ### 7.5 What the run against a real shard changed
The plan above was written from reading `PointsSystem.cs`. Booting the actual shard (ServUO 57.4, a The plan above was written from reading `PointsSystem.cs`. Booting the actual shard (ServUO 57.4, a
@@ -887,8 +948,8 @@ not a blocker here.)
| 3 | **C** — spawn atlas (§6) | website, docs | none | ✅ Done | | 3 | **C** — spawn atlas (§6) | website, docs | none | ✅ Done |
| 4 | **B/2**`points.board` (§7) | all four | new kind + `char.profile` field | ✅ Done | | 4 | **B/2**`points.board` (§7) | all four | new kind + `char.profile` field | ✅ Done |
| 5a | **B/3 dependency** — cliloc table (§8.6) | website, docs | none | ✅ Done | | 5a | **B/3 dependency** — cliloc table (§8.6) | website, docs | none | ✅ Done |
| 5b | **B/3**`vendor.listing` (§8) | all four | new kinds | 🟨 In review | | 5b | **B/3**`vendor.listing` (§8) | all four | new kinds | ✅ Done |
| 6 | **Cutover**`PROTOCOL_VERSION` 2→3, `edge``main` | all four | the bump | | | 6 | **Cutover**`PROTOCOL_VERSION` 2→3, `edge``main` | all four | the bump | 🟨 In review — `edge``main` held for Android parity (§10) |
--- ---
@@ -910,8 +971,24 @@ not a blocker here.)
- `npm run swagger` **and** `npm run routes:manifest` on every route-touching PR — both are committed - `npm run swagger` **and** `npm run routes:manifest` on every route-touching PR — both are committed
artifacts, and `test/routeManifest.test.js` fails on drift. artifacts, and `test/routeManifest.test.js` fails on drift.
**Follow-up, not scoped for 3.0:** the Android app consumes the same public/player shard API and will **Android parity — now scoped, and it gates the cutover (decided 2026-07-30).** This was written as a
need `/public/shard/features` to hide its own nav. Track separately against `android-app/`. "track separately" follow-up. It was re-examined before the cutover and the gap is wider than nav
hiding: the app consumes the same public/player shard API but has **no consumer for any of the four new
features** (`ruleset`, `leaderboards`, `market`, `atlas`), no `points` block on its character sheet, no
cliloc-resolved item names (§8.6), and — the part that matters for §3 — **it gates shard navigation on
session role alone**, so an admin who disables a feature or raises its audience leaves the app
rendering entries that `404`/`403` into a generic error where the web client hides them.
Two things were verified as already correct and are recorded so they are not re-derived: the app's SSE
request rides the same authenticated OkHttp client as every other call, so an app session resolves to
the same audience rung as the same account on the web; and every shard DTO in the app is
nullable-with-defaults, so field projection strips fields without a deserialization failure.
Scoped as **M11 in [`../android/PLAN.md`](../android/PLAN.md) §9**, two PRs (the visibility rules +
read-model adds, then the four screens). `edge``main` is held until both land, so web and app
surface the same shard on the same day. Neither PR is coupled to the merge order — on a pre-v3 website
every new route and `/public/shard/features` `404`s and the app falls back to today's behavior — so
holding the cutover is a schedule decision, not a technical dependency.
--- ---

View File

@@ -119,6 +119,20 @@ server/
vendors, chars, sales, houses vendors, chars, sales, houses
appeals.router.js (4) /player/appeals appeals.router.js (4) /player/appeals
shard.controller.js + appeals.controller.js 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 admin/ index.js mounts the capability routers below at their
own prefixes; owns the shared own prefixes; owns the shared
`noindex, isLoggedIn, staffOnly` gate and `noindex, isLoggedIn, staffOnly` gate and
@@ -151,7 +165,15 @@ server/
email.router.js (6) /admin/email — Gmail OAuth2 email.router.js (6) /admin/email — Gmail OAuth2
delivery — adminOnly delivery — adminOnly
discordBot.router.js (2) /admin/discord-bot — adminOnly discordBot.router.js (2) /admin/discord-bot — adminOnly
settings.router.js (2) /admin/settings — 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
dashboard.router.js (2) GET /dashboard (staff-wide) and dashboard.router.js (2) GET /dashboard (staff-wide) and
PUT /site-mode (adminOnly) — the PUT /site-mode (adminOnly) — the
two singletons owning no path two singletons owning no path
@@ -248,6 +270,61 @@ Seeded keys: `site_mode` (default `maintenance`), `site_mode_changed_at`,
`site_mode_changed_by`, `maintenance_message`, `status_message`, `homepage_teaser`, `site_mode_changed_by`, `maintenance_message`, `status_message`, `homepage_teaser`,
`contact_email` (=UOMysticmoon@gmail.com), `site_title`. `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 ### activity_log — append-only
| col | type | notes | | col | type | notes |
|---|---|---| |---|---|---|
@@ -602,7 +679,7 @@ are authoritative, and they answer different questions:
| Artifact | Source of truth for | Generated by | | Artifact | Source of truth for | Generated by |
|---|---|---| |---|---|---|
| `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/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/swagger/swagger-output.json` — served at `/api/docs` | **What each route means.** Parameters, bodies, response codes, security. | `npm run swagger`, from `#swagger.*` annotations | | `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 The split is deliberate: Swagger is annotation-derived, so an unannotated route is invisible in it and
@@ -778,7 +855,7 @@ from the per-route **siteMode** middleware (§5), never from an auth gate.
| Method | Path | Notes | | 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) — 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); 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 | `/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 | `/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 | `/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 | | GET | `/posts/:category` | published only; `category` ∈ news\|five-on-friday\|newsletter\|screenshots |
@@ -802,6 +879,18 @@ from the per-route **siteMode** middleware (§5), never from an auth gate.
Public content GETs pass through the **siteMode** gate (§5). 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 (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; `admin/index.js` applies the shared gate and mounts each capability router at the prefix it owns;
@@ -836,7 +925,9 @@ file a route sits in — that is the property the route manifest freezes.
| POST | `/posts/upload` | multipart image upload (multer) → `{image_url}` for screenshots | | POST | `/posts/upload` | multipart image upload (multer) → `{image_url}` for screenshots |
| GET | `/wiki` · GET `/wiki/:slug` | read incl. unpublished | | GET | `/wiki` · GET `/wiki/:slug` | read incl. unpublished |
| POST | `/wiki` · PUT `/wiki/:slug` · DELETE `/wiki/:slug` | manage pages | | POST | `/wiki` · PUT `/wiki/:slug` · DELETE `/wiki/:slug` | manage pages |
| GET | `/settings` · PUT `/settings` | read all / update `{key:value,...}` | | 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 | `/activity?limit=&offset=` | paginated activity log | | 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` · 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) | | GET | `/users/:id/trusted-devices` | list a user's active trusted devices (never tokens) |
@@ -852,6 +943,42 @@ file a route sits in — that is the property the route manifest freezes.
Every admin write logs to `activity_log`. 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) ## 5. Site mode (LIVE / MAINTENANCE)

View File

@@ -44,6 +44,11 @@ they did before the table existed.
## Converting ## Converting
> **Step-by-step operator instructions — where to get UOFiddler, where your
> client files are, and how to verify the import — are in
> [`UOFIDDLER.md`](UOFIDDLER.md).** This section covers the formats and the
> reasoning behind them.
Either format below is accepted; the site sniffs which one it was handed. Either format below is accepted; the site sniffs which one it was handed.
| Format | Fidelity | Notes | | Format | Fidelity | Notes |
@@ -77,8 +82,16 @@ dotnet run -- "<UOFiddler>/Ultima.dll" "<UO client>/Cliloc.enu" /srv/uo-data/cli
dotnet run -- "<UOFiddler>/Ultima.dll" "<UO client>/Cliloc.enu" /srv/uo-data/clilocs.tsv --tsv dotnet run -- "<UOFiddler>/Ultima.dll" "<UO client>/Cliloc.enu" /srv/uo-data/clilocs.tsv --tsv
``` ```
A UOFiddler GUI export works equally well — anything producing one of the two A UOFiddler GUI export works too, but **not unmodified**: its Cliloc tab writes
shapes above is fine. `Number;Text;Flag` — three columns, the flag *last* — and the parser reads
`number<separator>text`, so the trailing field is absorbed into the name and
every item renders as `quarter staff;0`. Stripping it is one `sed`, given in
[`UOFIDDLER.md`](UOFIDDLER.md) §Route B.
The parser already tolerates `number,flag,text`, with the flag in the *middle*.
It is not extended to cover the trailing form because a final `;0` is
indistinguishable from a name that genuinely ends that way — a heuristic there
would corrupt real names to save the operator one command.
## Shard-added and shard-edited items ## Shard-added and shard-edited items

View File

@@ -132,6 +132,7 @@ website/
│ │ │ │ ├── RecoveryCodesPanel.jsx │ │ │ │ ├── RecoveryCodesPanel.jsx
│ │ │ │ ├── TrustedDevicesPanel.jsx │ │ │ │ ├── TrustedDevicesPanel.jsx
│ │ │ │ └── TrustLimitModal.jsx │ │ │ │ └── TrustLimitModal.jsx
│ │ │ ├── BrandLogo.jsx
│ │ │ ├── CharacterSheet.jsx │ │ │ ├── CharacterSheet.jsx
│ │ │ ├── CharacterStats.jsx │ │ │ ├── CharacterStats.jsx
│ │ │ ├── CreateGameAccountForm.jsx │ │ │ ├── CreateGameAccountForm.jsx
@@ -140,6 +141,7 @@ website/
│ │ │ ├── MaintenanceGate.jsx │ │ │ ├── MaintenanceGate.jsx
│ │ │ ├── Modal.jsx │ │ │ ├── Modal.jsx
│ │ │ ├── MoonDot.jsx │ │ │ ├── MoonDot.jsx
│ │ │ ├── NavDropdown.jsx
│ │ │ ├── PageHeader.jsx │ │ │ ├── PageHeader.jsx
│ │ │ ├── PageState.jsx │ │ │ ├── PageState.jsx
│ │ │ ├── PlayersOnline.jsx │ │ │ ├── PlayersOnline.jsx
@@ -162,8 +164,13 @@ website/
│ │ ├── lib/ │ │ ├── lib/
│ │ │ ├── format.js │ │ │ ├── format.js
│ │ │ ├── heroLayout.js │ │ │ ├── heroLayout.js
│ │ │ ├── navOverrides.js
│ │ │ ├── settingsJson.js
│ │ │ ├── shardEvents.js │ │ │ ├── shardEvents.js
│ │ │ ├── themeVars.js
│ │ │ ├── useAsync.js │ │ │ ├── useAsync.js
│ │ │ ├── useNavOverrides.js
│ │ │ ├── useShardFeatures.js
│ │ │ └── useShardFeed.js │ │ │ └── useShardFeed.js
│ │ ├── routes/ │ │ ├── routes/
│ │ │ ├── admin/ │ │ │ ├── admin/
@@ -173,8 +180,10 @@ website/
│ │ │ │ │ ├── AdminCharacter.jsx │ │ │ │ │ ├── AdminCharacter.jsx
│ │ │ │ │ ├── AdminCharacters.jsx │ │ │ │ │ ├── AdminCharacters.jsx
│ │ │ │ │ ├── Appeals.jsx │ │ │ │ │ ├── Appeals.jsx
│ │ │ │ │ ├── AppearanceAdmin.jsx
│ │ │ │ │ ├── AuthProvidersAdmin.jsx │ │ │ │ │ ├── AuthProvidersAdmin.jsx
│ │ │ │ │ ├── BotActivityAdmin.jsx │ │ │ │ │ ├── BotActivityAdmin.jsx
│ │ │ │ │ ├── BrandAssetsPanel.jsx
│ │ │ │ │ ├── Dashboard.jsx │ │ │ │ │ ├── Dashboard.jsx
│ │ │ │ │ ├── DiscordBotAdmin.jsx │ │ │ │ │ ├── DiscordBotAdmin.jsx
│ │ │ │ │ ├── EmailDelivery.jsx │ │ │ │ │ ├── EmailDelivery.jsx
@@ -183,13 +192,17 @@ website/
│ │ │ │ │ ├── InvitesAdmin.jsx │ │ │ │ │ ├── InvitesAdmin.jsx
│ │ │ │ │ ├── Moderation.jsx │ │ │ │ │ ├── Moderation.jsx
│ │ │ │ │ ├── ModerationUser.jsx │ │ │ │ │ ├── ModerationUser.jsx
│ │ │ │ │ ├── NavEditor.jsx
│ │ │ │ │ ├── PageBuilder.jsx │ │ │ │ │ ├── PageBuilder.jsx
│ │ │ │ │ ├── PagesAdmin.jsx │ │ │ │ │ ├── PagesAdmin.jsx
│ │ │ │ │ ├── PostEditor.jsx │ │ │ │ │ ├── PostEditor.jsx
│ │ │ │ │ ├── PostsAdmin.jsx │ │ │ │ │ ├── PostsAdmin.jsx
│ │ │ │ │ ├── PublicNavTree.jsx
│ │ │ │ │ ├── SettingsAdmin.jsx │ │ │ │ │ ├── SettingsAdmin.jsx
│ │ │ │ │ ├── ShardAdmin.jsx │ │ │ │ │ ├── ShardAdmin.jsx
│ │ │ │ │ ├── ShardOps.jsx │ │ │ │ │ ├── ShardOps.jsx
│ │ │ │ │ ├── ShardVisibility.jsx
│ │ │ │ │ ├── SpawnAtlas.jsx
│ │ │ │ │ ├── UserDetail.jsx │ │ │ │ │ ├── UserDetail.jsx
│ │ │ │ │ ├── UserEditor.jsx │ │ │ │ │ ├── UserEditor.jsx
│ │ │ │ │ ├── UsersAdmin.jsx │ │ │ │ │ ├── UsersAdmin.jsx
@@ -213,17 +226,23 @@ website/
│ │ │ │ └── ResetPassword.jsx │ │ │ │ └── ResetPassword.jsx
│ │ │ ├── public/ │ │ │ ├── public/
│ │ │ │ ├── About.jsx │ │ │ │ ├── About.jsx
│ │ │ │ ├── Atlas.jsx
│ │ │ │ ├── AtlasCreature.jsx
│ │ │ │ ├── ChampSpawns.jsx │ │ │ │ ├── ChampSpawns.jsx
│ │ │ │ ├── CmsPage.jsx │ │ │ │ ├── CmsPage.jsx
│ │ │ │ ├── FiveOnFriday.jsx │ │ │ │ ├── FiveOnFriday.jsx
│ │ │ │ ├── Governors.jsx │ │ │ │ ├── Governors.jsx
│ │ │ │ ├── Guilds.jsx │ │ │ │ ├── Guilds.jsx
│ │ │ │ ├── Houses.jsx │ │ │ │ ├── Houses.jsx
│ │ │ │ ├── Leaderboards.jsx
│ │ │ │ ├── Maintenance.jsx │ │ │ │ ├── Maintenance.jsx
│ │ │ │ ├── Market.jsx
│ │ │ │ ├── MarketVendor.jsx
│ │ │ │ ├── News.jsx │ │ │ │ ├── News.jsx
│ │ │ │ ├── Newsletter.jsx │ │ │ │ ├── Newsletter.jsx
│ │ │ │ ├── NewsletterIssue.jsx │ │ │ │ ├── NewsletterIssue.jsx
│ │ │ │ ├── Portal.jsx │ │ │ │ ├── Portal.jsx
│ │ │ │ ├── Rules.jsx
│ │ │ │ ├── Screenshots.jsx │ │ │ │ ├── Screenshots.jsx
│ │ │ │ ├── Shard.jsx │ │ │ │ ├── Shard.jsx
│ │ │ │ ├── ShardActivity.jsx │ │ │ │ ├── ShardActivity.jsx
@@ -240,8 +259,11 @@ website/
│ │ ├── apiClient.test.js │ │ ├── apiClient.test.js
│ │ ├── format.test.js │ │ ├── format.test.js
│ │ ├── heroLayout.test.js │ │ ├── heroLayout.test.js
│ │ ├── navOverrides.test.js
│ │ ├── regionBuckets.test.js │ │ ├── regionBuckets.test.js
│ │ ── shardEvents.test.js │ │ ── settingsJson.test.js
│ │ ├── shardEvents.test.js
│ │ └── themeVars.test.js
│ ├── index.html │ ├── index.html
│ ├── package-lock.json │ ├── package-lock.json
│ ├── package.json │ ├── package.json
@@ -257,9 +279,12 @@ website/
│ └── sonar-test-reporter.mjs │ └── sonar-test-reporter.mjs
├── server/ ├── server/
│ ├── db/ │ ├── db/
│ │ ├── data/
│ │ │ └── spawnAtlas.art.example.json
│ │ ├── schema.sql │ │ ├── schema.sql
│ │ └── seed.js │ │ └── seed.js
│ ├── scripts/ │ ├── scripts/
│ │ ├── importSpawnAtlas.js
│ │ └── routeManifest.js │ │ └── routeManifest.js
│ ├── src/ │ ├── src/
│ │ ├── auth/ │ │ ├── auth/
@@ -294,6 +319,7 @@ website/
│ │ │ ├── brand.js │ │ │ ├── brand.js
│ │ │ ├── csp.js │ │ │ ├── csp.js
│ │ │ ├── notificationStreams.js │ │ │ ├── notificationStreams.js
│ │ │ ├── themePresets.js
│ │ │ └── version.js │ │ │ └── version.js
│ │ ├── middleware/ │ │ ├── middleware/
│ │ │ ├── botScore.js │ │ │ ├── botScore.js
@@ -365,15 +391,27 @@ website/
│ │ │ ├── settings/ │ │ │ ├── settings/
│ │ │ │ ├── settings.db.js │ │ │ │ ├── settings.db.js
│ │ │ │ └── settings.model.js │ │ │ │ └── settings.model.js
│ │ │ ├── shardAtlas/
│ │ │ │ ├── shardAtlas.db.js
│ │ │ │ └── shardAtlas.model.js
│ │ │ ├── shardClilocs/
│ │ │ │ ├── shardClilocs.db.js
│ │ │ │ └── shardClilocs.model.js
│ │ │ ├── shardEvents/ │ │ │ ├── shardEvents/
│ │ │ │ ├── shardEvents.db.js │ │ │ │ ├── shardEvents.db.js
│ │ │ │ └── shardEvents.model.js │ │ │ │ └── shardEvents.model.js
│ │ │ ├── shardLinks/ │ │ │ ├── shardLinks/
│ │ │ │ ├── shardLinks.db.js │ │ │ │ ├── shardLinks.db.js
│ │ │ │ └── shardLinks.model.js │ │ │ │ └── shardLinks.model.js
│ │ │ ├── shardMarket/
│ │ │ │ ├── shardMarket.db.js
│ │ │ │ └── shardMarket.model.js
│ │ │ ├── shardState/ │ │ │ ├── shardState/
│ │ │ │ ├── shardState.db.js │ │ │ │ ├── shardState.db.js
│ │ │ │ └── shardState.model.js │ │ │ │ └── shardState.model.js
│ │ │ ├── shardVisibility/
│ │ │ │ ├── shardVisibility.db.js
│ │ │ │ └── shardVisibility.model.js
│ │ │ ├── trustedDevices/ │ │ │ ├── trustedDevices/
│ │ │ │ ├── trustedDevices.db.js │ │ │ │ ├── trustedDevices.db.js
│ │ │ │ └── trustedDevices.model.js │ │ │ │ └── trustedDevices.model.js
@@ -398,12 +436,14 @@ website/
│ │ │ │ │ ├── account.router.js │ │ │ │ │ ├── account.router.js
│ │ │ │ │ ├── activity.router.js │ │ │ │ │ ├── activity.router.js
│ │ │ │ │ ├── admin.controller.js │ │ │ │ │ ├── admin.controller.js
│ │ │ │ │ ├── admin.routes.js
│ │ │ │ │ ├── authProviders.controller.js │ │ │ │ │ ├── authProviders.controller.js
│ │ │ │ │ ├── authProviders.router.js │ │ │ │ │ ├── authProviders.router.js
│ │ │ │ │ ├── botActivity.controller.js │ │ │ │ │ ├── botActivity.controller.js
│ │ │ │ │ ├── botActivity.router.js │ │ │ │ │ ├── botActivity.router.js
│ │ │ │ │ ├── dashboard.router.js
│ │ │ │ │ ├── discordBot.controller.js │ │ │ │ │ ├── discordBot.controller.js
│ │ │ │ │ ├── discordBot.router.js
│ │ │ │ │ ├── email.router.js
│ │ │ │ │ ├── emailConfig.controller.js │ │ │ │ │ ├── emailConfig.controller.js
│ │ │ │ │ ├── imageUpload.js │ │ │ │ │ ├── imageUpload.js
│ │ │ │ │ ├── index.js │ │ │ │ │ ├── index.js
@@ -414,16 +454,25 @@ website/
│ │ │ │ │ ├── pages.controller.js │ │ │ │ │ ├── pages.controller.js
│ │ │ │ │ ├── pages.router.js │ │ │ │ │ ├── pages.router.js
│ │ │ │ │ ├── posts.router.js │ │ │ │ │ ├── posts.router.js
│ │ │ │ │ ├── settings.router.js
│ │ │ │ │ ├── shard.router.js
│ │ │ │ │ ├── shardAtlas.controller.js
│ │ │ │ │ ├── shardClilocs.controller.js
│ │ │ │ │ ├── shardOps.controller.js │ │ │ │ │ ├── shardOps.controller.js
│ │ │ │ │ ├── shardVisibility.controller.js
│ │ │ │ │ ├── uoLink.controller.js │ │ │ │ │ ├── uoLink.controller.js
│ │ │ │ │ ├── uoLink.router.js
│ │ │ │ │ ├── uploads.router.js │ │ │ │ │ ├── uploads.router.js
│ │ │ │ │ ├── users.router.js │ │ │ │ │ ├── users.router.js
│ │ │ │ │ ├── usersShard.controller.js │ │ │ │ │ ├── usersShard.controller.js
│ │ │ │ │ └── wiki.router.js │ │ │ │ │ └── wiki.router.js
│ │ │ │ ├── auth/ │ │ │ │ ├── auth/
│ │ │ │ │ ├── auth.controller.js │ │ │ │ │ ├── auth.controller.js
│ │ │ │ │ ├── auth.routes.js │ │ │ │ │ ├── index.js
│ │ │ │ │ ├── invite.controller.js │ │ │ │ │ ├── invite.controller.js
│ │ │ │ │ ├── invite.router.js
│ │ │ │ │ ├── login.router.js
│ │ │ │ │ ├── loginGuards.js
│ │ │ │ │ ├── me.routes.js │ │ │ │ │ ├── me.routes.js
│ │ │ │ │ ├── mobile.controller.js │ │ │ │ │ ├── mobile.controller.js
│ │ │ │ │ ├── mobile.routes.js │ │ │ │ │ ├── mobile.routes.js
@@ -431,7 +480,10 @@ website/
│ │ │ │ │ ├── mobileSso.routes.js │ │ │ │ │ ├── mobileSso.routes.js
│ │ │ │ │ ├── notifications.controller.js │ │ │ │ │ ├── notifications.controller.js
│ │ │ │ │ ├── notifications.routes.js │ │ │ │ │ ├── notifications.routes.js
│ │ │ │ │ ├── password.router.js
│ │ │ │ │ ├── passwordReset.controller.js │ │ │ │ │ ├── passwordReset.controller.js
│ │ │ │ │ ├── register.router.js
│ │ │ │ │ ├── session.router.js
│ │ │ │ │ ├── sso.controller.js │ │ │ │ │ ├── sso.controller.js
│ │ │ │ │ ├── sso.routes.js │ │ │ │ │ ├── sso.routes.js
│ │ │ │ │ └── trustDevice.helper.js │ │ │ │ │ └── trustDevice.helper.js
@@ -439,13 +491,29 @@ website/
│ │ │ │ │ ├── internal.controller.js │ │ │ │ │ ├── internal.controller.js
│ │ │ │ │ └── internal.routes.js │ │ │ │ │ └── internal.routes.js
│ │ │ │ ├── player/ │ │ │ │ ├── player/
│ │ │ │ │ ├── account.router.js
│ │ │ │ │ ├── appeals.controller.js │ │ │ │ │ ├── appeals.controller.js
│ │ │ │ │ ├── player.routes.js │ │ │ │ │ ├── appeals.router.js
│ │ │ │ │ ── shard.controller.js │ │ │ │ │ ── index.js
│ │ │ │ │ ├── shard.controller.js
│ │ │ │ │ └── shard.router.js
│ │ │ │ ├── public/ │ │ │ │ ├── public/
│ │ │ │ │ ├── atlas.controller.js
│ │ │ │ │ ├── atlas.router.js
│ │ │ │ │ ├── index.js
│ │ │ │ │ ├── pages.router.js
│ │ │ │ │ ├── posts.router.js
│ │ │ │ │ ├── public.controller.js │ │ │ │ │ ├── public.controller.js
│ │ │ │ │ ├── public.routes.js │ │ │ │ │ ├── shard.controller.js
│ │ │ │ │ ── shard.controller.js │ │ │ │ │ ── 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 │ │ │ │ └── v1.router.js
│ │ │ ├── api.router.js │ │ │ ├── api.router.js
│ │ │ ├── cspReport.controller.js │ │ │ ├── cspReport.controller.js
@@ -455,16 +523,26 @@ website/
│ │ │ ├── auth.js │ │ │ ├── auth.js
│ │ │ ├── botInternalClient.js │ │ │ ├── botInternalClient.js
│ │ │ ├── botInternalKey.js │ │ │ ├── botInternalKey.js
│ │ │ ├── brandAssets.js
│ │ │ ├── clilocParse.js
│ │ │ ├── clilocSource.js
│ │ │ ├── db.js │ │ │ ├── db.js
│ │ │ ├── htmlShell.js
│ │ │ ├── logger.js │ │ │ ├── logger.js
│ │ │ ├── mailer.js │ │ │ ├── mailer.js
│ │ │ ├── navOverrides.js
│ │ │ ├── newsGump.js │ │ │ ├── newsGump.js
│ │ │ ├── pushDispatch.js │ │ │ ├── pushDispatch.js
│ │ │ ├── sanitizeHtml.js │ │ │ ├── sanitizeHtml.js
│ │ │ ├── secretBox.js │ │ │ ├── secretBox.js
│ │ │ ├── settingsJson.js
│ │ │ ├── shardBroadcast.js │ │ │ ├── shardBroadcast.js
│ │ │ ├── shardIngest.js │ │ │ ├── shardIngest.js
│ │ │ ├── shardSales.js │ │ │ ├── shardSales.js
│ │ │ ├── shardVisibility.js
│ │ │ ├── spawnAtlasParse.js
│ │ │ ├── spawnAtlasSource.js
│ │ │ ├── themeResolve.js
│ │ │ ├── totp.js │ │ │ ├── totp.js
│ │ │ ├── trustProxy.js │ │ │ ├── trustProxy.js
│ │ │ ├── uoLinkClient.js │ │ │ ├── uoLinkClient.js
@@ -483,14 +561,19 @@ website/
│ │ ├── appeals.pure.test.js │ │ ├── appeals.pure.test.js
│ │ ├── appeals.test.js │ │ ├── appeals.test.js
│ │ ├── appLinks.test.js │ │ ├── appLinks.test.js
│ │ ├── atlasController.test.js
│ │ ├── authController.test.js │ │ ├── authController.test.js
│ │ ├── authMe.test.js │ │ ├── authMe.test.js
│ │ ├── authTrustedDevice.test.js │ │ ├── authTrustedDevice.test.js
│ │ ├── botInternalKey.test.js │ │ ├── botInternalKey.test.js
│ │ ├── botScore.test.js │ │ ├── botScore.test.js
│ │ ├── brandAssets.test.js
│ │ ├── clilocParse.test.js
│ │ ├── clilocSource.test.js
│ │ ├── csp.test.js │ │ ├── csp.test.js
│ │ ├── emailConfig.model.test.js │ │ ├── emailConfig.model.test.js
│ │ ├── honeypot.test.js │ │ ├── honeypot.test.js
│ │ ├── htmlShell.test.js
│ │ ├── inviteController.test.js │ │ ├── inviteController.test.js
│ │ ├── invites.test.js │ │ ├── invites.test.js
│ │ ├── loginProtection.test.js │ │ ├── loginProtection.test.js
@@ -501,6 +584,7 @@ website/
│ │ ├── mobileSsoBridge.test.js │ │ ├── mobileSsoBridge.test.js
│ │ ├── moderation.model.test.js │ │ ├── moderation.model.test.js
│ │ ├── moderation.test.js │ │ ├── moderation.test.js
│ │ ├── navOverrides.test.js
│ │ ├── newsGump.test.js │ │ ├── newsGump.test.js
│ │ ├── notificationsRoutes.test.js │ │ ├── notificationsRoutes.test.js
│ │ ├── pages.model.test.js │ │ ├── pages.model.test.js
@@ -521,17 +605,34 @@ website/
│ │ ├── secretBox.test.js │ │ ├── secretBox.test.js
│ │ ├── selfTrustedDevices.test.js │ │ ├── selfTrustedDevices.test.js
│ │ ├── session.test.js │ │ ├── session.test.js
│ │ ├── settingsTheming.test.js
│ │ ├── shardBroadcast.visibility.test.js
│ │ ├── shardControllerPublic.test.js │ │ ├── shardControllerPublic.test.js
│ │ ├── shardIngest.champsPages.test.js │ │ ├── shardIngest.champsPages.test.js
│ │ ├── shardIngest.market.test.js
│ │ ├── shardIngest.points.test.js
│ │ ├── shardIngest.protocol2.test.js │ │ ├── shardIngest.protocol2.test.js
│ │ ├── shardIngest.ruleset.test.js
│ │ ├── shardMarket.model.test.js
│ │ ├── shardState.governorTerms.test.js │ │ ├── shardState.governorTerms.test.js
│ │ ├── shardState.model.test.js │ │ ├── shardState.model.test.js
│ │ ├── shardVisibility.test.js
│ │ ├── spawnAtlas.parse.test.js
│ │ ├── spawnAtlas.source.test.js
│ │ ├── ssoCallback.test.js │ │ ├── ssoCallback.test.js
│ │ ├── ssoState.test.js │ │ ├── ssoState.test.js
│ │ ├── ssoTrustedDevice.test.js
│ │ ├── themeResolve.test.js
│ │ ├── totp.test.js │ │ ├── totp.test.js
│ │ ├── trustedDevices.test.js │ │ ├── trustedDevices.test.js
│ │ ├── trustProxy.test.js │ │ ├── trustProxy.test.js
│ │ ├── uoLinkClient.test.js
│ │ └── usernamePolicy.test.js │ │ └── usernamePolicy.test.js
│ ├── tools/
│ │ └── cliloc-export/
│ │ ├── clilocexport.csproj
│ │ ├── Program.cs
│ │ └── README.md
│ ├── .env.example │ ├── .env.example
│ ├── package-lock.json │ ├── package-lock.json
│ ├── package.json │ ├── package.json

View File

@@ -223,7 +223,8 @@ The atlas is fully functional as text. `shard_spawn_creatures.art` is nullable
and is NULL on every fresh import; pages render without images, which is the and is NULL on every fresh import; pages render without images, which is the
normal and supported state, not a degraded one. normal and supported state, not a degraded one.
An operator who wants art: An operator who wants art — step-by-step, with the UOFiddler side spelled out, in
[`UOFIDDLER.md`](UOFIDDLER.md) §Part 2:
1. Extracts it from **their own** client files (UOFiddler, ClassicUO tooling, or 1. Extracts it from **their own** client files (UOFiddler, ClassicUO tooling, or
any art extractor). any art extractor).

981
website/THEMING_AND_NAV.md Normal file
View File

@@ -0,0 +1,981 @@
# 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.14.5 are blocking; §4.64.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 34 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 600700, 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 34 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 02 are one PR pair (website + docs), 34 a second, 5 a third, 68 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 02 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 34 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 68 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 68 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 68 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 34 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.

282
website/UOFIDDLER.md Normal file
View File

@@ -0,0 +1,282 @@
# Extracting from your own UO client (UOFiddler)
**Audience:** the shard operator, once, at setup time.
**Related:** [`CLILOCS.md`](CLILOCS.md) (why the cliloc conversion is unavoidable),
[`SPAWN_ATLAS.md`](SPAWN_ATLAS.md) (where creature art fits).
Two features read data that **only exists inside a UO client**, and a UO client's
files are EA's, not ours to redistribute. So neither this repo nor any image we
publish can ship them — the operator extracts from **their own** client, once,
and points the site at the result.
| Feature | What it needs | Required? | Without it |
|---|---|---|---|
| **Item / title names** ([`CLILOCS.md`](CLILOCS.md)) | `Cliloc.enu`, converted | No | Names render as raw ids — `id 1023721` instead of *quarter staff* |
| **Creature art** ([`SPAWN_ATLAS.md`](SPAWN_ATLAS.md)) | Sprites from `.mul`/`.uop` | No | Atlas pages render as text, which is the normal state |
**Both are optional and neither is load-bearing.** A shard that never does any of
this is fully supported. Do part one and skip part two if art is not worth your
time — they share only the tool.
Everything you extract stays **outside the repository**: the converted cliloc
file lives at a path you choose, and `spawnAtlas.art.json` plus `server/uploads/`
are gitignored, so none of it can be committed by accident.
---
## Part 0 — Get UOFiddler
[UOFiddler](https://github.com/polserver/UOFiddler) is the community client-file
editor. We use it because its `Ultima.dll` already contains the cliloc
decompressor, maintained by people who do this for a living.
1. Download the latest release zip from
<https://github.com/polserver/UOFiddler/releases/latest> — one asset, named
`UOFiddler-<version>.zip` (4.22.2 is ~2 MB).
2. Extract it. The zip contains a single top-level folder, and the two files that
matter are at **its root**:
```
UOFiddler-4.22.2/
Ultima.dll ← the decompressor (Part 1 needs this path)
UoFiddler.exe ← the GUI (Part 2 needs this)
plugins/
```
3. **Runtime:** UOFiddler 4.22.2 is built for **.NET 10**. Running `UoFiddler.exe`
needs the .NET 10 **Desktop** Runtime (Windows only); loading `Ultima.dll` from
the converter in Part 1 needs the .NET 10 runtime. Install from
<https://dotnet.microsoft.com/download/dotnet/10.0>.
### Finding your client files
The cliloc file is in your **UO client installation directory**, not in your
ServUO tree — the shard server has no copy of it. Look for `Cliloc.enu` (English;
the other seven are `chs`, `cht`, `deu`, `esp`, `fra`, `jpn`, `kor`) beside
`art.mul` / `artLegacyMUL.uop`. The EA Classic Client's default location is:
```
C:\Program Files (x86)\Electronic Arts\Ultima Online Classic\
```
**If your shard distributes its own patched client to players, use that copy.**
Any cliloc edits you shipped to players are then already in the base table and
you need no overlay for them (see [`CLILOCS.md`](CLILOCS.md) §Shard-added and
shard-edited items).
---
## Part 1 — Convert the cliloc table
**Goal:** turn the client's compressed `Cliloc.enu` into a file the site can
read, and point the site at it.
The site cannot read `Cliloc.enu` directly. Every modern client compresses it
(the "Mythic" container), and so does ServUO's own bundled `Ultima.StringList` —
which is why the shard cannot supply names on our behalf either. The full
reasoning is in [`CLILOCS.md`](CLILOCS.md) §Why the operator has to convert the
file; this section is just the procedure.
Two routes. **The bundled tool is the recommended one** — the GUI export needs a
fixup step, described below.
### Route A — the bundled converter (recommended)
Needs a .NET SDK (any version 8 or newer — the project targets `net8.0` and rolls
forward, so whatever you have works) **plus** the .NET 10 runtime from Part 0,
which is what actually loads `Ultima.dll`.
```bash
cd website/server/tools/cliloc-export
dotnet build -c Release
# plain binary — recommended, exact
dotnet run -c Release -- \
"/path/to/UOFiddler-4.22.2/Ultima.dll" \
"/path/to/UO client/Cliloc.enu" \
/srv/uo-data/clilocs.plain
# or tab-delimited text, if you want to eyeball or hand-edit it
dotnet run -c Release -- \
"/path/to/UOFiddler-4.22.2/Ultima.dll" \
"/path/to/UO client/Cliloc.enu" \
/srv/uo-data/clilocs.tsv --tsv
```
Expected output for a stock English client:
```
wrote 123490 entries to /srv/uo-data/clilocs.plain (maxTextBytes=12150, skippedOversize=0)
```
**Sanity-check that number.** A stock `Cliloc.enu` is ~123,000 entries. A few
hundred means it read something else and you should not ship the result. The
tool exits non-zero and says `no entries were written — is that a cliloc file?`
when it gets nothing at all.
The conversion runs on whatever machine has the client (usually Windows), and the
site reads the output wherever it runs — so **copy the output file to the server**
if those are different machines. It is a single self-contained file (~5 MB); the
`--tsv` form is larger but diff-able.
<details>
<summary>Errors you may hit</summary>
| Message | Cause |
|---|---|
| `Ultima.StringList not found — is that really UOFiddler's Ultima.dll?` | First argument points at some other `Ultima.dll` (ServUO ships one too — it is **not** the same assembly and cannot do this) |
| `You must install .NET to run this application` | Missing the .NET 10 runtime from Part 0 step 3 |
| `Unexpected Ultima.StringList API` | UOFiddler older than 4.21 |
| `usage: clilocexport …` | Fewer than three arguments |
</details>
### Route B — the UOFiddler GUI
Use this if you would rather not install a .NET SDK. **It needs one extra step**,
so do not skip the fixup.
1. Launch `UoFiddler.exe` and point it at your client directory when it asks
(or **Options → Path Settings**).
2. Open the **Cliloc** tab and use its **export to CSV** action.
3. It writes `CliLoc.csv` to UOFiddler's configured output path, in **three**
columns with a header row:
```
Number;Text;Flag
1023721;quarter staff;0
```
4. **Strip the trailing flag column.** The site's text parser reads
`number<TAB|,|;>text`, so that third field is otherwise absorbed into the name
and every item on the site renders as `quarter staff;0`.
```bash
sed -E 's/;[0-9]+$//' CliLoc.csv > clilocs.csv
```
```powershell
Get-Content CliLoc.csv |
ForEach-Object { $_ -replace ';\d+$','' } |
Set-Content -Encoding utf8 clilocs.csv
```
The header row needs no removal — a line whose first field is not an integer
is skipped. Blank entries (`1005008;`) survive the fixup correctly and are
dropped at import, as intended.
5. Copy `clilocs.csv` to the server.
**Why the fixup is not just done for us:** the parser already handles
`number,flag,text` — the flag in the *middle*, which is what several exports
emit. UOFiddler puts it at the *end*, where it is indistinguishable from a name
that genuinely ends in `;0`. One `sed` on the operator's side beats a parser
heuristic that would corrupt real names.
### Point the site at it
Two ways, the setting winning over the environment:
| Where | How |
|---|---|
| **Admin → Shard → cliloc path** | Takes effect on the next refresh, no redeploy |
| `UO_CLIENT_PATH` env var | The deploy-time default |
The value may be **the file itself or a directory to search** — both are natural
answers to "where is it", and overlays are picked up either way.
Setting the path deliberately does **not** import as a side effect. Click
**Import** (or `POST /api/v1/admin/shard/clilocs/import`) to load it.
### Verify
`GET /api/v1/admin/shard/clilocs`, or the Admin → Shard panel, reports what each
source contributed:
```json
"sources": [
{ "label": "clilocs.plain", "kind": "base", "entries": 123490, "added": 123490, "overrode": 0 }
]
```
Roughly **67,500 rows stored** from a stock table is correct — about half a
cliloc table is empty strings for ids the client reserves and never uses.
Then load any character sheet with equipment: items should show names rather than
`id 1023721`.
<details>
<summary>What a refusal means</summary>
A bad file answers `200` with a `status` and a named reason, not a `500` — you
need to be told *which file* to fix.
| `code` | Meaning |
|---|---|
| `COMPRESSED` | You pointed at the raw client `Cliloc.enu`. Convert it — this whole page. |
| `TRUNCATED` | Half-copied file. Re-copy; the loaded table is untouched. |
| `EMPTY` | A text source with no parseable rows — the file is named in the reason. |
| `status: needsReview` + `missingSources` | A previously-loaded source has vanished (unmounted volume? deliberate deletion?). Nothing changes until you re-import with `{ "approve": true }`. |
</details>
### Custom items — do *not* re-export for these
Shard-added items carry ids no client table has. Drop a small delimited file in a
`custom/` directory beside the base file and re-import:
```
/srv/uo-data/
clilocs.plain ← base, from this guide
custom/
01-uomysticmoon.tsv ← your additions and overrides
```
Files are read in sorted order and **later sources win**, so an overlay both adds
new ids and overrides stock ones you have re-purposed. **Adding one item never
means re-exporting a 5 MB client file.** Details in [`CLILOCS.md`](CLILOCS.md).
---
## Part 2 — Creature art for the spawn atlas (optional)
**Goal:** put sprites on atlas pages. Purely cosmetic — the atlas is fully
functional as text, and `art` is NULL on every fresh import.
**This project ships no art and no art-extraction tooling, and never will.**
1. In `UoFiddler.exe` (paths configured as in Route B step 1), open the
**Animations** tab for creature sprites — or **Items** for object art — find
the creature, and export as PNG. Right-click an entry for its export options,
or use the tab's *Export All* action for a batch. (4.22.2 added an export
option to the Animation tab's thumbnail list, which is the convenient one
here.)
2. Put the images under `server/uploads/atlas/`.
3. Copy `server/db/data/spawnAtlas.art.example.json` to `spawnAtlas.art.json` in
the same directory and map creature slugs to file names:
```json
{
"lizardman": "lizardman.png",
"orc": "orc.png"
}
```
**Keys are the slugs the atlas API reports**, derived from the type names in
your own shard's `Spawns/*.xml` — read them off the atlas rather than guessing.
A creature with no entry renders without art, which is the default.
4. Restart, or `npm run atlas:import -- --force`.
The art map is re-read on every atlas refresh, so adding one image is an edit plus
a refresh. Both `spawnAtlas.art.json` and `server/uploads/` are gitignored.
---
## Licensing, briefly
UO's strings and sprites are EA's. Extracting from **your own** client for
**your own** shard is the arrangement here; redistributing the extracted files is
not something this project does or can advise on. That is the whole reason this
page exists instead of a download link.

View File

@@ -249,6 +249,14 @@
"method": "PUT", "method": "PUT",
"path": "/api/v1/admin/settings" "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", "method": "POST",
"path": "/api/v1/admin/shard/account" "path": "/api/v1/admin/shard/account"
@@ -293,6 +301,18 @@
"method": "GET", "method": "GET",
"path": "/api/v1/admin/shard/char/:serial" "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", "method": "GET",
"path": "/api/v1/admin/shard/houses" "path": "/api/v1/admin/shard/houses"
@@ -817,10 +837,30 @@
"method": "GET", "method": "GET",
"path": "/api/v1/public/shard/idoc" "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", "method": "GET",
"path": "/api/v1/public/shard/online" "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", "method": "GET",
"path": "/api/v1/public/shard/presence" "path": "/api/v1/public/shard/presence"
@@ -860,6 +900,14 @@
{ {
"method": "GET", "method": "GET",
"path": "/api/v1/public/wiki/tags" "path": "/api/v1/public/wiki/tags"
},
{
"method": "GET",
"path": "/api/v1/settings/nav"
},
{
"method": "GET",
"path": "/api/v1/settings/theme/options"
} }
], ],
"internal": [ "internal": [