Compare commits

...

92 Commits

Author SHA1 Message Date
e733ac0c9c Merge pull request 'docs(website): settle boot/shutdown dispatch and what a boot writes down' (#130) from docs/module-lifecycle into main
Reviewed-on: #130
2026-08-11 02:32:48 +00:00
004e217806 docs(website): settle boot/shutdown dispatch and what a boot writes down
Records phase 2 PR 5 of the module system: the lifecycle hooks a module
registers, how they are dispatched, and what a boot does to installed_modules.

MODULE_API.md 2.5 gains the reconcile's four steps in order, the rules that fall
out of them (the operator's `disabled` wins over any outcome; a bookkeeping
failure is not a boot failure; a module with no onBoot still reaches `started`;
a module whose onBoot threw gets no onShutdown), and why onBoot has no timeout
while onShutdown has a five-second budget -- shutdown races the process being
killed and boot does not.

4.4 gains the failure_stage table: every failure is recorded against the 4.3
step that produced it, so the admin panel can say where a module broke and not
only what the message was.

MODULE_SYSTEM.md 2.4 records the new rule for a row whose directory is gone, and
2.7 the PR 5 progress entry with its four decisions. BACKEND_DESIGN.md's
installed_modules section gains the write path now that one exists.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 20:32:10 -05:00
30589f1fa5 Merge pull request 'docs(website): settle the three de-entanglement registries and what they cost' (#129) from docs/module-registries into main
Reviewed-on: #129
2026-08-10 23:16:46 +00:00
58b435be70 docs(website): settle the three de-entanglement registries and what they cost
Phase 2 PR 4 of MODULE_SYSTEM.md §2.7. Records what §1.8's three entangled files
and §1.9's extension slot actually became, and four decisions taken with them.

MODULE_API.md §2.4:
- registerNotificationStreams takes the catalog ALONE. `mapEvent` was a leftover
  from before §1.8's push inversion was settled — a module owns fromShardEvent
  and calls publish() with an id it resolved, so core never needs a second route
  to the same place. It follows that the public-safety filter is module-internal,
  which is the right home: the kinds, the streams and the filter become one file
  that moves together.
- the entry shape is two booleans, not a single `scope` — that object is the body
  of GET /auth/me/notifications/streams and a shipped Android client reads both.
- registerAnnounceLeg gains `label`, so a module's leg renders in the admin panel
  with no client change; and legs became `announce_job_legs` ROWS, because a
  module cannot ALTER a core table and a registered leg had nowhere to live.
- every call STAGES; nothing commits until the module as a whole is known good.

New §6.5 — the eight grandfathered names (seven stream ids, one leg id), allowed
to `uo` alone by explicit allowlist, the same shape as the loader's legacy table
prefixes. Grandfathering rather than dropping the rule is what keeps it real for
every module written after this one.

New §6.6 — an extension slot is invisible to static analysis, so core needs §6.1's
fragment merge for its OWN slot fills, a phase earlier than the plan expected.
Moving the six users-detail routes behind the slot deleted 407 lines from
swagger-output.json while printing `Success`. Which slots and where each hangs are
both derived — from the registry and from the live express stack — because a
written-down copy drifts.

MODULE_SYSTEM.md §1.8 / §1.9 record the outcome per file, and §2.7 the progress.
BACKEND_DESIGN.md and website-README.md follow the moved files; the PUBLIC_KINDS
reference at §"visibility" named the wrong file and is corrected to
utils/shardBroadcast.js.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 17:51:02 -05:00
bcd3a750e7 Merge pull request 'docs(website): settle how module schema fragments are validated and replayed' (#128) from docs/module-schema-replay into main
Reviewed-on: #128
2026-08-10 22:05:57 +00:00
63e6c2b5d1 docs(website): settle how module schema fragments are validated and replayed
Docs half of website PR 3 (phase 2). MODULE_SYSTEM.md 2.7 gains the PR 3 entry;
MODULE_API.md 2.6 gains the decisions the section did not previously answer.

The one that shapes the code: a fragment is VALIDATED at load time and EXECUTED
later, split on whether a database is needed to know the answer. Everything 2.6
states about the SQL is knowable by reading the file, so breaking one of those
rules costs a module its mount entirely rather than mounting and 503ing with its
tables half created. What is left for the replay is the class of failure only
the server can report, and that is post-mount and answers 503.

Also recorded: the rules are enforced as a leading-verb allowlist (CREATE,
ALTER, INSERT, UPDATE -- the four core's own schema.sql uses) rather than the
DROP denylist 2.6 words them as, because the file is replayed on every boot and
a denylist only ever bans what somebody thought of; the replay sits outside
ensureSchema's wait-for-the-database retry loop, so one module's bad SQL cannot
cost the boot two minutes; partial application is accepted, since MariaDB
self-commits DDL and no transaction could undo it; and `npm run seed` is the one
sanctioned caller that replays nothing, because it never requires app.js and so
has no scan to read.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 16:58:11 -05:00
510d10b297 Merge pull request 'docs(website): settle the module loader's trigger and its collision check' (#127) from docs/module-loader into main
Reviewed-on: #127
2026-08-10 21:30:52 +00:00
d7054fd1f2 Merge pull request 'docs(website): plan the Integration Kit — the module-builder's instruction book' (#126) from docs/module-integration-kit into main
Reviewed-on: #126
2026-08-10 21:30:29 +00:00
9355f2aec4 docs(website): settle the module loader's trigger and its collision check
Records what phase 2 PR 2 decided against the two things MODULE_API.md
left open for it.

7.6 is settled as an explicit modules.load(tierRouters) call in app.js
rather than a require-time scan: the loader needs the tier routers handed
to it for the 4.3 check, which a require-time side effect cannot receive,
and a require's position enforces an ordering constraint invisibly.

4.1 and 4.3 gain the mechanics that fall out -- where the call must sit
in app.js and why in both directions, that mounting is a second pass
after validation, and that core's prefix ownership is probed on the live
tier routers with express's layer.match() rather than declared in a table
that was already stale in the spike.

2.7 records PR 2 as done.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 14:36:22 -05:00
bb0e6a02fe docs(website): plan the Integration Kit, the module-builder's instruction book
RunicGateway/Integration-kit -- empty today, exactly where Module-uo was at the
start of Phase 0 -- becomes the book someone reads to build a module for a game
that is not UO, starting from nothing. It is the only document in the project
written for an audience outside the org.

One book rather than a page in each repo, because the job spans all three layers
of the data path: the website module (the bulk of it), the sidecar and WHY it
exists -- the shard is never network-reachable, it dials out, the wire is a
versioned contract -- and the game-side plugin that feeds it without ever letting
a wedged sidecar stall the game.

The rule that keeps it from rotting: it never re-specifies a contract. MODULE_API.md
stays normative for the module surface and link/PLAN.md + INTEGRATION.md for the
wire protocol; the kit teaches and links out for the authority. A guide that
restates a contract diverges from it silently, and the reader who follows the
divergent copy gets a module that fails validation for reasons the guide cannot
explain.

It trails the implementation rather than leading it -- scaffolded when Phase 2
lands, written against Phase 3's extraction with Module-uo as the worked example,
finished alongside Phase 4 -- and it is a draft until someone builds a working
module for a second game from it alone, without reading core's source.

Adds 2.11, Phase 5 in 2.7, and settled decision 13.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 14:10:41 -05:00
b5277d827c Merge pull request 'docs(website): where the module state machine lives, and how boot treats it' (#125) from docs/module-state-machine into main
Reviewed-on: #125
2026-08-10 19:02:48 +00:00
3510c2ecf1 docs(website): settle where the module state machine lives and how boot treats it
Phase 2 PR 1 of the module system records two things 2.4 left open: the
states are stored in one `state` column rather than a policy flag beside a
runtime one, and every boot recomputes the outcome states while leaving
`disabled` alone.

That second rule is the one with consequences worth writing down -- a
startup_failed module is retried on every restart, so an operator who fixes
the cause needs no admin-panel visit; a running module can never display a
stale failure reason; and disabling, the one operator decision rather than
outcome, survives restarts. Also states what the row does NOT decide: the
loader scans the filesystem before the database is reachable, so the URL
surface is a property of the volume, which is what keeps
routes.manifest.json generatable against a dead database.

BACKEND_DESIGN.md 3 gains the installed_modules columns alongside the other
tables.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 06:35:37 -05:00
27bfdc9152 Merge pull request 'docs(website): the module API contract, validated by the atlas spike (phase 1)' (#124) from docs/module-api into main
Reviewed-on: #124
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-10 11:16:46 +00:00
037f4abad2 docs(website): record what the atlas spike proved
Part 7 of MODULE_API.md: the three exit criteria and their results, the two
contract changes the spike forced (ctx.express/ctx.validator and
window.__rg.jsxRuntime), the empirical confirmation of the OpenAPI split, the
loader's tested failure guarantees, and the three artifacts in the branch that
are consequences of stopping at six routes rather than intended shape.

Phase 1 is complete: docs/website/MODULE_API.md is written and the spike met
every exit criterion on website branch spike/module-atlas (cut from edge, never
merged).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 05:30:05 -05:00
8db52c3d14 docs(website): settle the OpenAPI fragment merge and the curated UI kit
Both approved by the org lead 2026-08-10. §6.1 moves from open question to
decision A with the per-side obligations spelled out in a new §6.1a: modules
ship a swagger-fragment.json with fully-qualified paths and namespaced schema
keys, core merges started modules' fragments into /api/docs.json at request
time and always wins a key collision, and swagger-output.json stays exactly
what core's own routes generate.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 04:52:59 -05:00
2c2a5ccd57 docs(website): add the module API contract (phase 1)
The normative contract between core and an installed module: the `ctx` handed
to a module's entry point, the `register*` calls, the client-side registry and
shared-dependency global, the schema-fragment rules, and the loader's
validation and failure obligations. Every member is derived from what the UO
code actually imports today, re-read against the working tree.

Part 6 records four places the survey contradicted MODULE_SYSTEM.md:

  • OpenAPI generation is STATIC analysis (swagger-autogen parses app.js as
    text), unlike routeManifest.js which walks the live Express stack. A
    filesystem-scanning loader is invisible to it, so module routes would be
    silently absent from swagger-output.json. Three options, one recommended;
    needs a decision before Phase 2.
  • The client contract is much larger than §2.1 implies — the atlas pages
    import five core modules beyond React, so the plan needs a curated UI kit
    and a request primitive on window.__rg.
  • shardVisibility is module-owned and the atlas depends on it, so the spike
    carries it; two copies coexist during the spike by design.
  • Two counts corrected: 27 UO tables (not 25), 6 atlas routes (not 5).

MODULE_SYSTEM.md gains a pointer to the contract and the corrected table count.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 03:44:57 -05:00
07d8390a17 Merge pull request 'docs(website): record the edge branch strategy and the Module-uo repo' (#123) from docs/module-system-edge into main
Reviewed-on: #123
2026-08-10 07:32:55 +00:00
315574d846 docs(website): record the edge branch strategy and the Module-uo repo
Website work lands on an `edge` branch and reaches `main` as a single cutover
at the end -- the same shape used for protocol v3 and the Android theming
workstream. A core that has grown a module loader but not yet lost its UO
code is a coherent state; a core mid-extraction is not.

Adds a Phase 0, because the strategy is blocked on one line of CI config:
pr-checks.yml is `on: pull_request: branches: [main]`, so every PR into edge
would run no checks at all -- the same trap that let all nine Android M12
phase PRs merge with zero CI. It matters more here, since Phase 2's exit
criterion IS a CI result (zero-line routes.manifest.json diff, passing
tests). The fix must land on website main before the first module PR.

Also records the real module repo: RunicGateway/Module-uo (capital M, as
Android-app), currently empty -- no branches, no initial commit -- and
disambiguates the repo name from the module id `uo`.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 02:30:45 -05:00
ab6699dad7 Merge pull request 'docs(tree): sync android/PROJECT_TREE.md' (#121) from chore/sync-android-tree into main
Reviewed-on: #121
2026-08-10 07:24:42 +00:00
53b9a2347a Merge pull request 'docs(website): add the module system design of record' (#122) from docs/module-system into main
Reviewed-on: #122
2026-08-10 07:19:35 +00:00
0cc561334c docs(website): add the module system design of record
Turns Runic Gateway from a UO-specific platform into a game-agnostic one:
game-specific routes, tables, screens and nav leave the core website and
become an installable module. Operators install the base site, install the
module for their game, and restart -- they never build anything.

Verified against the working trees rather than written from the draft. The
draft's load-bearing assumptions that did not survive:

  * one flat module route prefix cannot coexist with URL preservation, since
    UO routes span three access tiers -- modules own a named slot per tier
  * there is no server-side nav list, and navOverrides.js refuses to have one,
    so nav registration is client-side
  * the nav feature-gate mechanism is itself the shard visibility system
  * there is no migration system to model a module runner on -- schema.sql is
    replayed idempotently every boot, so modules ship fragments
  * boot/shutdown holds eight UO call sites with no hook to receive them
  * notificationStreams, pushDispatch and announceWorker are entangled, not moves
  * six UO routes are nested under the core users resource
  * the Discord bot has no UO logic at all
  * the installer never contacts the website and its Bundle is hardcoded to
    two components, so delivery is website-side
  * routeManifest and swagger walk app.js with no DB, so modules mount
    synchronously from the filesystem
  * production is a prebuilt pull-only image and CSP forbids inline script,
    which together decide how the client half loads

Ten decisions are recorded as settled in Part 3.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 01:44:29 -05:00
runic-docs-bot
7d452494a4 docs(tree): sync android/PROJECT_TREE.md from RunicGateway/Android-app@c55ee7f [skip ci] 2026-08-08 16:29:11 +00:00
f665f75bbb Merge pull request 'docs(android): M12 theming & navigation, all phases as landed (cutover)' (#120) from edge into main
Reviewed-on: #120
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-08 16:14:27 +00:00
433a3b920b Merge pull request 'docs(android): M12 phase 8 as landed, and the two defects AC-5 found' (#119) from docs/android-theming-nav-phase-8 into edge
Reviewed-on: #119
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-08 16:06:28 +00:00
6305d86a01 docs(android): M12 phase 8 as landed, and the two defects AC-5 found
Phase 8 was scoped as docs, coverage and the cutover. The AC-5 walk turned it
into a phase with code in it, which is the outcome the walk existed to produce -
so the record is longer than a phase-8 note would normally be.

THEMING_AND_NAV.md gains "Phase 8 as landed": what the walk confirmed, the two
defects it found and why they are phase 2's trap repeating, the role sweep that
says which Material roles are still unmapped and which of them have a reader, the
coverage-glob narrowing, and the adb-reverse rig note for the next walk. The
phase 2 note is amended where it claimed the card shadow was the milestone's only
visible change to an untouched instance - it is now one of three, and the third
(an untouched instance drawing a hero, because brand.hero carries the env
default) is a correction to the record with no code behind it.

PLAN.md's M12 entry stopped at phase 2; it now runs through 6, records phase 8
including the defects, and drops the same "one deliberate change" claim.

COVERAGE_PLAN.md gains an amendment section explaining why the ui/theme/**
directory glob became wrong the moment M12 put three pure resolvers behind it,
and the general rule it suggests: prefer file globs to directory globs once a
directory is mixed. Its own §5 already forbade excluding testable code.

Pairs with Android-app phase 8.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 11:02:46 -05:00
9091f0c760 Merge pull request 'docs(android): M12 phase 6 as landed' (#118) from docs/android-theming-nav-phase-6 into edge
Reviewed-on: #118
2026-08-08 12:59:18 +00:00
bbfd7a7789 docs(android): M12 phase 6 as landed
Corrects §6.3's link-resolution table, which named three website paths the site
does not serve (/site/news/<idOrSlug>, /page/<slug>, /contact) and missed two it
does (/site/atlas/:slug, /site/market/vendors/:serial), and records the four
drawer decisions taken before code: the /<slug> CMS catch-all with the site's
reserved segments excluded, the static section header, the hand-off icon, and
reusing LocalAssetResolver for the Custom Tab's absolute URL.

Adds "Phase 6 as landed" and ticks the phase in §8.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 07:49:22 -05:00
79eaa9ceef Merge pull request 'docs(android): M12 phase 5 as landed' (#117) from docs/android-theming-nav-phase-5 into edge
Reviewed-on: #117
2026-08-08 12:12:20 +00:00
02d1663c1a docs(android): M12 phase 5 as landed
§6.2 corrected and amended, §6.1 given the Home decision, and a "Phase 5 as
landed" section added with the ✅ in §8.

**§6.2's table was in the wrong order, and the order is now load-bearing.** The
three news categories were listed Five on Friday / Newsletter / Screenshots; the
website's array has them Screenshots / Five on Friday / Newsletter. That did not
matter while the table was only a mapping, but a stored `order` is an index into
the site's nav, so a row the admin never moved takes its sort key from this list —
and a key from the wrong list scrambles a partially-overridden nav. The rows are
now numbered 0-15 and the order is called out as verbatim.

Also written into §6.2: the `feature` values are deliberately **not** mirrored
into the table (`APP_MENU` stays the app's own source of truth for gating), and
the "no `APP_MENU` row" rule covers **seven** entries rather than four — the
three news categories as well as the hub boards.

Two decisions the org lead settled before code, both recorded where the rule
lives rather than only in the phase notes:

- **The news categories are ignored for the drawer**, on the same rule as the hub
  four. The mapping still exists for phase 6's added links, where a category tab
  is a perfectly good destination because the admin named it by path. The
  alternative — surfacing such a row only when overridden — keeps AC-1 but lets an
  override introduce navigation after all, so it was rejected.
- **An admin may hide the Home row**, mirroring the website, where `/` is hideable
  from the public header. Home stays the start destination and stays reachable by
  back-press. The contrast that makes it safe is with `/admin/navigation`, which
  has three guards because hiding it would strip the only way to undo an override;
  nothing about a hidden Home row is unrecoverable.

"Phase 5 as landed" records the sort-key finding above as the thing the spec did
not settle, that an untouched instance gets `APP_MENU` back by **identity** so
AC-1's drawer claim is an `assertSame`, that the app's own rows are partitioned
off rather than sorted (and what that would mean for a future interleaved row),
that `group`/`section` are read and dropped, and why `Routes.NEWS_ROUTE` is
declared beside `Routes.NEWS` rather than replacing it — with the consequence that
`destination.route` now carries a query and is compared on `substringBefore('?')`.

Android-app: RunicGateway/Android-app#38.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 07:09:44 -05:00
67e110201b Merge pull request 'docs(android): M12 phase 4 as landed' (#116) from docs/android-theming-nav-phase-4 into edge
Reviewed-on: #116
2026-08-08 11:51:19 +00:00
317dc310fa docs(android): M12 phase 4 as landed
§5.6 gains the three decisions the drafted section left open — the top bar's
text fallback, the hero's fixed cropped band, and the accessibility split
between a decorative logo and a named one — each with the reasoning that
settled it, so none is re-litigated from scratch. Adds the 32dp/24dp logo
heights, the six-times-height width cap, and the note that the app takes no
fallback hero image where the website substitutes its own emblem.

"Phase 4 as landed" records how the empty-slot rule is enforced by layout
rather than by a conditional at each call site, why the blank check runs on
both sides of the resolver, and the honest limit of the phase's tests: the
drawing cannot be tested here at all, since the app has no Robolectric and no
androidTest source set, so only the decision of whether to draw is pure.

Phase 4 ticked in §8.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 06:14:36 -05:00
6526f32a42 Merge pull request 'docs(android): M12 phase 3 as landed' (#115) from docs/android-theming-nav-phase-3 into edge
Reviewed-on: #115
2026-08-08 10:51:22 +00:00
d95632db04 docs(android): M12 phase 3 as landed
§5.3 rewritten against what the build measured rather than what the plan
estimated, plus a "Phase 3 as landed" section and the ✅ in §8.

Three corrections to the drafted section:

- The APK estimate was wrong by more than 3×. "Roughly 1.5-2.5 MB" becomes a
  measured before/after table: 5,031,411 B → 13,574,703 B, +8.15 MiB. The
  section now records that Merriweather is 6.08 MiB of that on its own, and the
  two cheaper options the org lead costed and declined, so the number is not
  re-litigated from scratch later.
- Family lookup must be global, never scoped to the role's option list. Two of
  the three presets publish a font their own role's dropdown does not offer,
  because resolveThemeTokens copies preset tokens verbatim — the same bypass
  §5.4 already records for --shadow-card.
- Italics mirror the website's four rather than being skipped, and every
  bundled family supplies the four weights the scale asks for (with IM Fell
  English's single weight and Cinzel's untouched 500/600/700 as the two
  documented exceptions), because a family is not confined to the role its
  dropdown lives in.

Android-app: RunicGateway/Android-app#TBD

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 05:46:02 -05:00
ea8c5b9a42 Merge pull request 'docs(android): M12 phase 2 as landed' (#114) from docs/android-theming-nav-phase-2 into edge
Reviewed-on: #114
2026-08-08 10:16:48 +00:00
1746ad4e37 docs(android): M12 phase 2 as landed
The radii-and-shadow phase, shipped as Android-app#35. Seven notes, of which
three are amendments to the spec rather than records of it.

Section 5.4's elevation map is not a no-op and now says so, with section 2 and
AC-1 amended to match. Material3's filled Card is Level0 and FeatureCard drew
none of the shadow its own KDoc claimed, so the app has been flat since M5 -
while the runic-gateway preset it was drawn from selects the "Default" shadow.
The org lead chose to apply the map as written rather than rebase it on the flat
baseline, because rebasing would have collapsed none/Soft/Default onto 0dp and
left only Deep doing anything on the phone. The radius half is untouched by this
and remains a provable no-op.

Section 5.4's exact-string match is corrected to a nearest-blur match. The
fantasy preset's own --shadow-card is not one of SHADOW_OPTIONS' four values,
because resolveThemeTokens copies a preset's tokens verbatim and they never pass
through the admin form's dropdown.

Section 5.2 gains the rule the pill needed: CircleShape is a percentage, so it
has no shipped dp for a ratio to scale and the resolved px is taken as dp below
the 500px floor. It is a literal because there is no app scale to preserve, not
as an exception to the ratio rule.

Also recorded: why all 24 Card( call sites became ShardCard( (Material takes
elevation as a default argument, not from the theme), that --radius-pill reaches
only StatusPill, and that Shapes - unlike ColorScheme - does implement equals,
so the structural no-op proof is one assertion rather than a reflection walk.

PLAN.md 9's M12 entry records phase 2 landed.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 05:15:21 -05:00
afe0e9245f Merge pull request 'docs(android): M12 phase 1 as landed' (#113) from docs/android-theming-nav-phase-1 into edge
Reviewed-on: #113
2026-08-08 09:57:00 +00:00
a860557e91 docs(android): M12 phase 1 as landed
The colors phase, shipped as Android-app#34. Seven notes, of which three are
departures the build forced rather than choices it made:

ColorScheme has no equals in material3 1.3.0, so AC-1's "full equality, not a
spot check" is a field-by-field compare by reflection over all 36 color roles,
against a verbatim copy of the pre-M12 scheme held in the test.

ShardPillFg is derived from --accent-bright rather than being a sixteenth
token, under the rule 5.1 already states for ShardOnCta.

An instance with an env accent and no theme_visual row loses its accented CTA
buttons, because brand.accent now seeds --accent alone. That is 5.1's "wrong
twice over" being corrected, and it is the one thing on screen that moves.

PLAN.md 9's M12 entry records phase 1 landed.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 04:55:11 -05:00
beae4b21dd Merge pull request 'docs(android): M12 phase 0 as landed, and phase 7 cancelled' (#112) from docs/android-theming-nav-phase-0 into edge
Reviewed-on: #112
2026-08-08 07:01:55 +00:00
11999c4bcd docs(android): M12 phase 0 as landed, and phase 7 cancelled
Two records, one milestone.

Phase 7 (reading nav_admin / nav_player) is cancelled. Section 6.4 already
made the case against it and scheduled it last so the call could be taken on
its merits with the rest working: the two authenticated navs reach four app
rows between them, which does not pay for a new authenticated fetch, a
session-keyed cache and its teardown. The app therefore makes no authenticated
settings call at all, and the player and staff drawer rows keep their coded --
and so localized -- labels, which is the one thing given up. Section 4's locked
decision, section 3's endpoint row and section 5.5's lifecycle paragraph are
amended to match so the doc does not contradict itself, and section 8 gains
the standing "Phase 7, cancelled" note.

Phase 0 (the contract and the appearance store) landed as Android-app#33, and
its "as landed" notes record two departures from what section 8 specified:

- `theme` is modeled as a raw JsonElement rather than Map<String,String>?.
  kotlinx fails the decode of the whole object on a value of an unexpected
  kind, and `theme` shares its payload with `brand` and `push` -- so one odd
  token would have blanked the branding and dropped the push relay URL, the
  opposite of section 2. It is coerced field-by-field instead.
- A failed refresh keeps the last good appearance rather than falling back to
  NONE. Section 5.5 said best-effort; the distinction it did not draw is that
  a moment of no connectivity on resume must not repaint a themed shard back
  to the shipped defaults.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 01:58:48 -05:00
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
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
17 changed files with 5276 additions and 127 deletions

View File

@@ -14,11 +14,18 @@ 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/`
| Doc | What it covers |
|---|---|
| [BACKEND_DESIGN.md](website/BACKEND_DESIGN.md) | API contract, DB schema, security model |
| [HERO_EDITOR.md](website/HERO_EDITOR.md) | Hero canvas editor feature spec |
| [THEMING_AND_NAV.md](website/THEMING_AND_NAV.md) | Admin-configurable theme, brand assets and navigation — build contract |
| [MODULE_SYSTEM.md](website/MODULE_SYSTEM.md) | Making the site game-agnostic: game logic becomes an installable module — design of record |
| [MODULE_API.md](website/MODULE_API.md) | The module ↔ core contract: `ctx`, the `register*` calls, the client registry and the loader's obligations |
| [WIKI_UPGRADE.md](website/WIKI_UPGRADE.md) | Wiki subsystem upgrade notes |
| [SHARD_VISIBILITY.md](website/SHARD_VISIBILITY.md) | Who sees which shard data — the admin-configurable audience framework |
| [SPAWN_ATLAS.md](website/SPAWN_ATLAS.md) | The bestiary / spawn atlas: what the shard contains, parsed from its own ServUO tree |
@@ -54,7 +61,7 @@ ci/ cross-cutting CI/quality notes
### `installer/`
| Doc | What it covers |
|---|---|
| [INSTALL.md](installer/INSTALL.md) | **Operator guide** — installing Runic Gateway on a ServUO shard, connecting it to the website, and diagnosing it. Includes the by-hand path, which works today |
| [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

View File

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

View File

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

View File

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

1143
android/THEMING_AND_NAV.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -3,17 +3,15 @@
Operator guide for the **Runic Gateway installer** — the tool that takes a working ServUO
installation and connects it to a Runic Gateway website.
> **Status: the installer binary is not released yet.**
> **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).
>
> Everything it installs *is* released and published — the sidecar, the plugin overlay, and the
> [bundle manifest](https://gitea.whitlocktech.com/RunicGateway/installer/src/branch/main/bundles/current.json)
> that names the checked combination of the two. This guide is the operator-facing contract those
> phases build to, and it is written first on purpose: it is the specification of what the run
> looks like, what it asks, where it writes, and what it prints.
>
> **You can install today without it** — [Appendix A](#appendix-a--installing-by-hand) is the same
> deployment done by hand, with the commands verified against the current releases. When the binary
> ships, Appendix A stays as the reference for what it does under the hood.
> [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).
@@ -27,7 +25,7 @@ Three things, on the machine that runs your shard:
|---|---|---|
| 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 a cached copy of any patches it applied | Written by the installer |
| 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
@@ -53,7 +51,7 @@ Only the sidecar is exposed, and only to your website.
| 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)* | The base install works on any reasonably current ServUO. The patch tier is verified against stock 57.4 only, and is skipped with a warning on anything else. |
| 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. |
@@ -75,10 +73,16 @@ Download the installer for your OS, plus `SHA256SUMS`, from the
```
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
@@ -140,8 +144,9 @@ one file); `doctor`, `update` and `uninstall` are run from it later. Examples be
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, and not offered at all if your
ServUO is not 57.4. See [§4](#4-the-patch-tier-optional).
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.
@@ -165,16 +170,19 @@ Overlay sync
ADD Config/Bridge.cfg
ADD Scripts/Custom/Bridge/*.cs (22 files)
CHANGE Scripts/Scripts.csproj
deployed. add=23 change=1 unchanged=0
deployed. add=23 change=1 unchanged=0 kept=0
Patch tier skipped (not selected)
Patch tier not selected
Without it: no vendor.sale events, no in-game moderation audit forwarding.
uo-link
binary /usr/bin/runicgateway-link
config /etc/runicgateway/sidecar.toml (created)
database /var/lib/runicgateway/uo-link.db
service runicgateway-link.service enabled, running
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
@@ -201,11 +209,18 @@ reports "unchanged" and writes nothing.
| `--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` | Decide the patch tier non-interactively. `--patches` still refuses on a non-57.4 tree. |
| `--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. |
| `--purge` | `uninstall` | Also delete `sidecar.toml` and `uo-link.db`, which are otherwise kept. |
| `--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).
---
@@ -218,7 +233,9 @@ reports "unchanged" and writes nothing.
| `/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 any patches applied, so `uninstall` can print the exact hunks long after the release tarball is gone |
| `/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 |
@@ -230,8 +247,11 @@ reports "unchanged" and writes nothing.
| `%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 |
| Service `RunicGatewayLink` | Automatic start, restart on failure |
| `%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):
@@ -241,11 +261,30 @@ Scripts/Custom/Bridge/*.cs 22 files: the plugin itself
Scripts/Scripts.csproj OVERWRITES a stock file (see below)
```
Both service definitions pin `UOLINK_CONFIG` and `UOLINK_DB_PATH` explicitly. The sidecar's own
defaults are relative to its working directory, and a service manager's working directory is not
somewhere you want a database — on Windows it can be `%SystemRoot%\System32` or, under
**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
@@ -272,15 +311,87 @@ 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 (`git apply --check`) before anything is
applied, and reported per patch. Most real shards are hand-modified; a patch that does not apply
is expected, not alarming.
- **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.
- **Skipped entirely on a ServUO that is not 57.4**, with a warning. Unverified diffs are never
applied to an unknown tree.
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.
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
@@ -398,21 +509,43 @@ The command that makes this supportable. Run it before asking anyone for help
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 of 3 applied — vendor.sale unavailable
✓ uo-link installed 1.1.0
✓ Service running, enabled
✓ Sidecar reachable 127.0.0.1:8080 /health ok
⚠ 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 shard has dialed in since boot
✗ 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
```
Three of those 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 both `install.json` and the release
manifest, which is how it tells "you edited a deployed file" from "the overlay moved on".
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`
@@ -426,6 +559,31 @@ together — never to two independently-latest artifacts that may disagree.
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
@@ -434,12 +592,19 @@ your work.
| | |
|---|---|
| **Removed** | The sidecar binary, its service entry, `install.json`, the cached patch set |
| **Kept** | `sidecar.toml` and `uo-link.db` — config and history survive (`--purge` drops them) |
| **Printed, not done** | Every overlay file deployed into your ServUO tree, by path, for you to delete |
| **Printed, not done** | The exact hunks each applied patch added to `EventSink.cs`, `PlayerVendorGumps.cs` and `Logging.cs`, for you to revert |
| **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. |
The report is also written to a file, so it survives the scrollback.
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.
---
@@ -447,6 +612,7 @@ The report is also written to a file, so it survives the scrollback.
| 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`. |
@@ -454,6 +620,9 @@ The report is also written to a file, so it survives the scrollback.
| 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. |
@@ -463,15 +632,20 @@ The report is also written to a file, so it survives the scrollback.
## Appendix A — installing by hand
This is what the installer automates. It works today, on the current releases, and is the fallback
whenever you would rather not run an unsigned binary.
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/main/bundles/current.json
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.
@@ -513,10 +687,20 @@ 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 the core files are CRLF: use `patch --binary`.
`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
@@ -596,15 +780,43 @@ Copy-Item .\uo-link-sidecar-windows-x86_64.exe "$env:ProgramFiles\RunicGateway\u
& "$env:ProgramFiles\RunicGateway\uo-link-sidecar.exe" --print-config --config "$env:ProgramData\RunicGateway\sidecar.toml"
sc.exe create RunicGatewayLink binPath= "\"$env:ProgramFiles\RunicGateway\uo-link-sidecar.exe\"" start= auto
# 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
[Environment]::SetEnvironmentVariable('UOLINK_CONFIG', "$env:ProgramData\RunicGateway\sidecar.toml", 'Machine')
[Environment]::SetEnvironmentVariable('UOLINK_DB_PATH', "$env:ProgramData\RunicGateway\uo-link.db", 'Machine')
# 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
```
Machine environment variables are read at service start, so set them before starting — and never
leave the config path to the default, which is relative to the service's working directory.
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

View File

@@ -1,19 +1,54 @@
# Runic Gateway Installer — plan
Status: **Phase 0 complete.** Every prerequisite in another repo has landed, the installer repo
publishes the bundle manifest, and [`INSTALL.md`](INSTALL.md) now specifies the operator-facing run
— so *what* the installer installs and *what using it looks like* both exist ahead of the binary.
No installer code exists yet; **Phase 1 is next.** This document is the design of record; it
supersedes the informal overview it grew out of, which described a ServUO integration that does not
match how `servuo-plugins` actually ships (see
Status: **Shipped.** All five phases are built, the `edge → main` cutover merged on 2026-08-07
([installer#17](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/17)), and it cut the
first release —
[`v0.1.0`](https://gitea.whitlocktech.com/RunicGateway/installer/releases/tag/v0.1.0), publishing
`linux-x86_64`, `linux-aarch64` and `windows-x86_64.exe` plus `SHA256SUMS`. The binary does
everything [`INSTALL.md`](INSTALL.md) describes: the installer core (bundle resolution, ServUO
detection and validation, the overlay sync, `install.json` — [Phase 1 as
built](#phase-1--installer-core)), the sidecar half (binary, config, service, token handoff —
[Phase 2 as built](#phase-2--uo-link-install-and-service)), the patch tier (the rung ladder, the
unsupported-version path, the cached patch set — [Phase 3 as built](#phase-3--patch-tier-opt-in)),
the day-two commands `doctor`, `update` and `uninstall` ([Phase 4 as
built](#phase-4--diagnostics-and-updates)), and the packaging polish that gated the cutover
([Phase 5](#phase-5--packaging-polish)).
**The installer is now the supported way to set a shard up**, and the operator guide leads with it.
`INSTALL.md`'s [Appendix A](INSTALL.md#appendix-a--installing-by-hand) remains supported rather than
deprecated — it is the path for a host that cannot run the binary, for an operator who wants to
place files themselves, and for developing on the bridge from a working tree.
**Phase 5 came before the cutover, not after it** (org lead, 2026-08-05). The earlier order — cut
the release, then polish — would have published a first release immediately superseded by the next,
and the release layout is exactly what Phase 5 changed. Both cutover gates were met before the
merge:
1. **Phase 5, packaging polish** (§5) — scope settled and built: **no `.deb` and no MSI** (§5.1 —
both would give the service, its unit and its user a second owner), **Linux `aarch64` for both
components** (§5.2), **a backup of what an upgrade overwrites** (§5.3), and the docs a first
release invalidates (§5.4).
2. **The Windows SCM half verified on a real host**, 2026-08-07. It was worth insisting on: the
first real `sc start` failed with **1053**, because the SCM waits ~30s for a handshake a plain
console program cannot perform. Fixed in the sidecar
([link#29](https://gitea.whitlocktech.com/RunicGateway/link/pulls/29), v1.2.0) and verified end
to end against a live service — 13/13 checks, start in 1s, `RUNNING`, `/health` served, service
log written, clean stop —
with [installer#16](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/16) making the
installer diagnose 1053 as a handshake rather than blaming the config. systemd registration was
verified separately on 2026-08-05 against a real privileged systemd container, and that run
likewise found a bug no unit test had.
This document is the design of record; it supersedes the informal overview it grew out of, which
described a ServUO integration that does not match how `servuo-plugins` actually ships (see
[Corrections](#corrections-to-the-original-overview)).
| Phase 0 item | State |
|---|---|
| 0.1 `servuo-plugins` release workflow | ✅ Merged — [servuo-plugins#7](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/7) + [#8](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/8); first overlay release is [`v0.1.1`](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/releases/tag/v0.1.1) |
| 0.2 `link` installable (data paths + `--print-config`) | ✅ Merged — [link#24](https://gitea.whitlocktech.com/RunicGateway/link/pulls/24) (docs half [docs#84](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/84)); released as [`v1.1.0`](https://gitea.whitlocktech.com/RunicGateway/link/releases/tag/v1.1.0) |
| 0.3 Bundle CI in the installer repo | ✅ Merged — [installer#3](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/3), plus the dispatch step in each component ([link#25](https://gitea.whitlocktech.com/RunicGateway/link/pulls/25), [servuo-plugins#9](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/9)). First bundle: [`2026.08.04`](https://gitea.whitlocktech.com/RunicGateway/installer/src/branch/main/bundles/current.json) |
| 0.4 This file + `INSTALL.md` | 🟨 In review — [docs#87](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/87). [`INSTALL.md`](INSTALL.md) is the operator guide, written before the binary because it *is* the specification of the run |
| 0.3 Bundle CI in the installer repo | ✅ Merged — [installer#3](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/3), plus the dispatch step in each component ([link#25](https://gitea.whitlocktech.com/RunicGateway/link/pulls/25), [servuo-plugins#9](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/9)). First bundle: [`2026.08.04`](https://gitea.whitlocktech.com/RunicGateway/installer/src/branch/bundles/current.json) |
| 0.4 This file + `INSTALL.md` | ✅ Merged — [docs#87](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/87). [`INSTALL.md`](INSTALL.md) is the operator guide, written before the binary because it *is* the specification of the run |
| — Repo bootstrap (governance + CI) | ✅ [`RunicGateway/installer`](https://gitea.whitlocktech.com/RunicGateway/installer) created; workflows merged ([installer#1](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/1), [#2](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/2)) |
---
@@ -44,7 +79,7 @@ release/start scripts. The installer never writes a launcher.
| Composition | **Published bundle manifest** (§7.1). CI names an exact, protocol-checked combination of component versions; the installer fetches it at run time and `--bundle <tag>` pins one. Component releases regenerate JSON, not the installer binary |
| Token handoff | **Print token + prefilled admin URL** at the end of the run |
| Repo | **New repo**, `RunicGateway/installer`. It deploys *both* other components, so living inside `link/` would invert the dependency |
| ServUO version | **Warn and skip.** Patches are verified against stock 57.4 only; on anything else the base install proceeds and the patch tier is skipped with a warning. Forks are the norm in a public audience — refusing outright would block most operators |
| ServUO version | **57.4 is the only supported version.** The patch tier's gate is content, not a version string: a patch applies where the lines it edits are still stock and is handed to the operator where they are not (§2.2.1). Forks and hand-edited trees are the norm in a public audience, so a non-57.4 tree is still *allowed* to attempt the tier — but **unsupported, untested and not guaranteed**, behind a loud banner, a defaulted-to-no prompt and its own opt-in flag (§2.2.2) |
| Uninstall | **Never touches the ServUO tree.** Removes uo-link and its service entry, then *prints* the overlay files to delete and the patch hunks to revert. Reverting is the operator's call |
---
@@ -96,12 +131,102 @@ most real shards are hand-modified. Therefore:
audit forwarding**.
- The `EventSink.cs` patch must warn loudly that a **core solution rebuild** is required, not just a
shard restart.
- On any ServUO version other than stock **57.4**, skip the whole tier with a warning and continue
with the base install. Do not attempt to apply unverified diffs to an unknown tree.
- Record applied patches in `install.json`, **and cache the applied `.patch` files** next to it
- Record applied patches in `install.json`, **and cache the `.patch` files** next to it
(`/etc/runicgateway/patches/`, `%ProgramData%\RunicGateway\patches\`). Re-runs stay idempotent,
and uninstall can print the exact hunks offline long after the release tarball is gone (§5,
Phase 4).
Phase 4). As built this caches every patch the tier *evaluated*, not only those that applied,
because the refusal message names that path as the file to apply by hand. **The cache outlives an
uninstall** — see Phase 4, where the report that would have been left pointing at a deleted
directory is what settled it.
- **Cache the pre-image of every file the tier edits**, under `patches/originals/`, mirroring its
path in the ServUO tree. It is written before the first edit and never overwritten, so a revert
can be verified byte-for-byte rather than reconstructed from a printed diff — which matters most
after a `region-match` apply, where the surrounding file was already the operator's. It stays out
of the ServUO tree, since uninstall has promised never to clean up in there.
- **A `.patch` does not carry everything the tier needs.** Which patches form one unit, which
companion `.cs` follows which, whether a core rebuild is required and what declining costs are
declared by the overlay release and read from its manifest — see §7.0.
#### 2.2.1 A whole-file hash mismatch is not a verdict — check the region
A file-level hash compare answers "is this entire file stock?", which is the wrong question. The
patches touch three small regions of three large files; an operator who added a custom command to
`Logging.cs` or a hook to `EventSink.cs` has changed the file's hash without going anywhere near the
lines the patch edits. Refusing on the file hash alone hands most real shards a manual patch job
they did not need. So the decision is made in three rungs, cheapest and safest first, and only the
last one gives up:
| Rung | Test | Outcome |
|---|---|---|
| **0 — already applied** | The hunk's *post*-patch text appears in the file | No-op, recorded as applied. Keeps re-runs idempotent |
| **1 — file is stock** | Whole-file hash matches the patch's pre-image (`index <old>..<new>` in the diff — `git hash-object` on the target reproduces it) | Apply verbatim with `git apply` |
| **2 — region is stock** | File differs, but every hunk's stock-side region is still byte-identical | Apply hunk-by-hunk at the matched offsets |
| **3 — region is modified** | Anything else | **Do not touch the file.** Print the path, the hunks and what is lost; the operator patches by hand |
Rung 2 is the semantic review, and it needs no new metadata: a unified diff already carries the
stock text of the region it edits — the context lines plus the `-` lines *are* the pre-image. For
each hunk the installer reconstructs that block and searches the target file for it, under these
rules:
- **Exact match, not fuzzy.** Only line-ending (CRLF/LF) and trailing-whitespace normalization is
allowed. No `patch --fuzz`, no context reduction: dropping context to force a match is precisely
how a patch lands in the wrong method.
- **Exactly one occurrence, or it fails.** Zero means the region moved or was edited. More than one
means the anchor is ambiguous and the installer cannot know which the author meant. Both are
rung 3.
- **Line numbers are advisory.** The hunk header's offsets are used only to prefer the nearest
candidate when reporting; the match itself is by content, since insertions above the region shift
every number below it.
- **All-or-nothing per patch file, and again per feature.** If one hunk of a patch reaches rung 3,
none of that patch's hunks are applied — a half-patched `EventSink.cs` compiles against a
companion `.cs` that expects the whole thing, and a partial apply is harder for an operator to
unpick than an untouched file. The same rule then applies across the patches of one feature: the
two vendor-sale patches are a unit (the event, the call site, and the subscriber that needs both),
so a patch that *could* have been placed is held back when a sibling cannot be — and the run says
that rather than reporting it as applied.
- **Rung 0 is checked first and is also all-or-nothing.** A file where some hunks are already
present and others are not is a hand-merge in progress, not an idempotent re-run — that is
rung 3.
#### 2.2.2 Non-57.4 is allowed, unsupported, and must say so loudly
The rung ladder replaces the blanket ServUO-version gate. The old rule skipped the entire tier on
anything other than stock 57.4 on the grounds that unverified diffs must not be applied to an
unknown tree — but forks are the norm (§1), so that rule skipped the tier for most of the audience.
Content matching gives a stronger guarantee than a version string does: on a non-57.4 tree, rung 1
is simply unavailable (its pre-image hash cannot be trusted), the tier goes straight to rung 2, and
a hunk lands only where the surrounding lines are still character-for-character the ones the patch
was written against.
**That is a mechanical safety guarantee about where text lands. It is not a support commitment, and
the installer must never let the two be confused.** Runic Gateway is designed, built and tested
against **stock ServUO 57.4**. On anything else the patch tier is **unsupported, untested, and not
guaranteed to work** — a hunk can match textually and still be wrong for a tree whose surrounding
behaviour has diverged, and neither the shard's silent script build (§2.1) nor the installer will
tell you that. So:
- **The disclaimer is unmissable, not a footnote.** On a non-57.4 tree the tier prints a banner
before it is even offered — that 57.4 is the only supported version, that the operator is on their
own here, and that a bad outcome may not surface until the shard is running.
- **It is off by default and takes an explicit, separate yes.** The interactive prompt defaults to
**no** on a non-57.4 tree, and `--patches` alone is **not** consent: an unattended run must pass
`--patches-unsupported-servuo` as well. A flag an operator had to look up cannot be hit by
accident in a script copied from somewhere else.
- **The label follows the install.** `install.json` records the detected version and the fact that
the tier ran unsupported; `doctor` shows that row on every subsequent run, not just at install
time; and the uninstall report carries it too. An operator who inherits this shard six months
later must be able to see it without being told.
- **It is the first thing quoted back in a bug report.** The tier's summary line names the detected
version, so a pasted install log answers "which ServUO?" before anyone asks.
The version is detected and reported everywhere; it just no longer *silently* decides. A refusal
becomes an informed choice, which is the point — but it stays visibly the operator's choice.
**What the installer records.** `install.json` stores, per patch, which rung applied it
(`stock-hash`, `region-match`, `already-present`) and the hunk offsets it matched. `doctor` and
`uninstall` report that: a `region-match` apply on a modified file is a different support story from
a clean apply to a stock tree, and the operator should be able to see which one they have without
re-deriving it.
### 2.3 Config paths collide with what the sidecar actually reads
@@ -122,13 +247,21 @@ Under `C:\Program Files\` that fails or silently lands in VirtualStore. Phase 0.
half in the sidecar — a relative `[store].path` now resolves against the directory holding
`sidecar.toml`, so pinning the config alone is enough to put the database somewhere deterministic —
but the config path itself is still CWD-relative by default, and "deterministic" is not the same as
"where this install wants it". The service definitions therefore still pin `UOLINK_CONFIG` and
`UOLINK_DB_PATH` explicitly:
"where this install wants it". The service definition therefore always pins the **config** path:
- Linux: config `/etc/runicgateway/sidecar.toml`, db `/var/lib/runicgateway/uo-link.db`, dedicated
service user
- Windows: binary under `%ProgramFiles%\RunicGateway\`, **data under `%ProgramData%\RunicGateway\`**
**How each is pinned differs by platform, and Phase 2 settled it that way deliberately.** Linux's
unit carries `Environment=UOLINK_CONFIG=` *and* `Environment=UOLINK_DB_PATH=`, because `/etc` and
`/var/lib` are different directories and both need naming. Windows passes the config as `--config`
inside the service's own `binPath`, and pins nothing else: config and data are both
`%ProgramData%\RunicGateway`, so the sidecar's own anchoring rule already puts the database exactly
where the table above says. The alternative on Windows is a **machine-wide** environment variable —
`sc.exe` offers no per-service one — which every process on the host would inherit and which would
outlive an uninstall. See [Phase 2 as built](#phase-2--uo-link-install-and-service).
### 2.4 The token handoff was missing entirely
The whole point is the website reaching the sidecar, and today that is manual and undocumented in
@@ -159,9 +292,11 @@ operators.
- **`servuo-plugins` had no release workflow.** Only `link` did. "Pull latest repository" is replaced
by a release tarball, which had to be built first — Phase 0 item 1, now in review
([servuo-plugins#7](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/7)).
- **arm64 is not buildable today.** `link/release.yml` cross-compiles only
`x86_64-unknown-linux-gnu` and `x86_64-pc-windows-gnu`. An arm64 `.deb` needs another cross
toolchain.
- **arm64 was not buildable today.** `link/release.yml` cross-compiled only
`x86_64-unknown-linux-gnu` and `x86_64-pc-windows-gnu`, so `platform_key()` refused every other
host by name. That was a property of the workflows, not of Rust — Phase 5 adds
`aarch64-unknown-linux-gnu` to both components in the order §5.2 sets out. There is no `.deb`, so
the cross toolchain is the whole cost.
- **The compat matrix has no home.** `PROTOCOL_VERSION` lives in `link/sidecar/src/main.rs`. The
sidecar publishes it via `X-UOLink-Version` and `/health`, and the website stores an expected
value — but the *plugin's* protocol version is not queryable before boot. Phase 0 item 1 gives it
@@ -178,12 +313,13 @@ Components are published as Gitea release artifacts. Operators download from the
Runic Gateway Installer v1.0.0
├── runicgateway-installer-windows-x86_64.exe
├── runicgateway-installer-linux-x86_64
├── runicgateway-installer-linux-aarch64 (Phase 5)
└── SHA256SUMS
uo-link v1.1.0 (existing release, extended)
├── uo-link-sidecar-windows-x86_64.exe
├── uo-link-sidecar-linux-x86_64
├── runicgateway-link_<ver>_amd64.deb (Phase 5)
├── uo-link-sidecar-linux-aarch64 (Phase 5)
└── SHA256SUMS
servuo-plugins v<ver> (new release, Phase 0)
@@ -227,7 +363,7 @@ The docs must state this up front rather than let users discover it as a scary d
┌────────┴────────┐ ┌────────┴────────┐
▼ ▼ ▼ ▼
overlay sync patch tier (opt-in) binary install service registration
(never deletes) (git apply + guard) + config + data (systemd / Windows SCM)
(never deletes) (per-region rungs) + config + data (systemd / Windows SCM)
```
Each component keeps its own lifecycle. ServUO's existing startup process is untouched.
@@ -309,7 +445,9 @@ Repo work that must land before an installer can exist.
- **Bundles are committed to the installer repo, not published as releases** — see §7.1 for
where and why. That was the one genuinely open question here, and the deciding factor is that
this repo's *own* releases are the installer binaries.
this repo's *own* releases are the installer binaries. They were committed to `main` until
2026-08-05, when the first run that actually had a bundle to write found `main` protected;
they now live on a `bundles` branch (§7.1).
- **Gate 1 reads the sidecar's protocol from source at the release tag**, not from the binary.
`--print-config` (Phase 0.2) would answer authoritatively, but only for releases from `v1.1.0`
onward, and `--bundle <tag>` has to be able to recompose a bundle from an older pair. Reading
@@ -342,12 +480,12 @@ Repo work that must land before an installer can exist.
might exist; it is the specification of what the run asks, where it writes, what it prints, and
what the operator does next. Phase 1–4 implement it.
- **It is useful before the installer exists.** Appendix A is the same deployment done by hand —
bundle fetch, tarball verify + overlay copy, the optional patch tier, `--print-config`
provisioning, and a systemd unit / `sc create` service — composed from the released artifacts'
actual contents and the sidecar's config and CLI source rather than from memory. That appendix
doubles as **Phase 1's acceptance test**: walking it end to end on a real shard is what proves
the automated path has nothing left to discover.
- **It was useful before the installer existed, and outlives it.** Appendix A is the same
deployment done by hand — bundle fetch, tarball verify + overlay copy, the optional patch tier,
`--print-config` provisioning, and a systemd unit / `sc create` service — composed from the
released artifacts' actual contents and the sidecar's config and CLI source rather than from
memory. That appendix doubles as **Phase 1's acceptance test**: walking it end to end on a real
shard is what proves the automated path has nothing left to discover.
- **The installer does not install itself.** §5's `runicgateway doctor` sketch implied a name on
`PATH`; nothing places one there, and adding self-installation would give the tool a second
lifecycle to manage. The guide names the downloaded artifact, says to keep it, and shortens it
@@ -383,6 +521,79 @@ Repo work that must land before an installer can exist.
timestamp.
- Idempotent re-runs; a second run with no upstream change reports "unchanged" and writes nothing.
**As built** ([installer#4](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/4)) — the
crate at the repo root, `install` implemented end to end, `doctor`/`update`/`uninstall` parsed and
answered with the phase they arrive in rather than "unrecognized command". The decisions that were
not already settled above:
- **It lands on `edge`, not `main`.** `release.yml` publishes an installer binary on every push to
`main`, and its crate guard was written to arm "the moment Phase 1 lands the crate" — which would
have published a binary that deploys the overlay but cannot install the sidecar, contradicting
everything `INSTALL.md` promises a release does. Phases 1 and 2 land on `edge`; the `edge → main`
cutover cuts the first release. `pr-checks.yml` gates PRs into `edge` on the same rules, so the
branch where the work happens is not the ungated one. No workflow needed a temporary edit.
- **The run says what it did *not* do.** A Phase 1 `install` ends with an unmissable block naming
the sidecar as not installed, pointing at `INSTALL.md` A3/A4, and printing the bundle's binary URL
and SHA256 so a hand install matches the pair. `--patches` is the sharp edge here: it is accepted
(so the flag surface is the published one) but reports `REQUESTED BUT NOT APPLIED — no stock
ServUO file has been touched`. A `--patches` run that completed quietly would be read as a
patched shard.
- **The crate is a library plus a thin binary, and the library is not named after it.** Windows
applies UAC *installer detection* to unsigned executables whose file name contains `install`: it
demands elevation before the process starts, and a non-interactive session gets `os error 740`
instead of a program. That is survivable for the shipped binary — it needs Administrator anyway,
and `INSTALL.md` already says to run it from an elevated shell — but Cargo names test harnesses
after their target, so a target called `runicgateway_installer` makes `cargo test` **unrunnable on
Windows**, on the machine the shard smoke tests live on. The code therefore sits in a library
called `rgdeploy`, the binary target keeps its published name, and `[[bin]] test = false` stops
Cargo building a harness under it. Nothing an operator sees changes.
- **Dependencies chosen for the MinGW cross-build:** `ureq` (blocking HTTP over rustls/ring — no
OpenSSL to cross-compile, and no async runtime for a tool that makes four sequential requests),
`flate2` on its pure-Rust backend, `tar`, `sha2`, `serde`/`serde_json`, `chrono`, `anyhow`, and
`sysinfo` for the running-shard check.
- **The shard-running check matches by path, not by process name.** `deploy.ps1` can look for a
process called `ServUO` because it only runs on Windows; on Linux the same shard is `mono` or
`dotnet` with `ServUO.exe` as an argument, and a name match would answer "not running" for a live
shard — the one wrong answer that corrupts `Scripts.dll`. The installer requires a process whose
executable or command line names *both* the tree being deployed into and `ServUO.exe`, so a second
shard elsewhere on the host does not block this deploy, and the installer never matches itself.
- **ServUO's version is read from `Server/AssemblyInfo.cs`**, not from `ServUO.exe`'s PE metadata:
it is the same *source* tree the patch tier diffs against, needs no dependency, and works
identically on Linux. `57.4.0.0` and `57.4` are normalized to compare equal. An unreadable version
is reported as `unknown` and treated as **not** supported — an unreadable version is not evidence
of a good one — which is what Phase 3 will gate the tier on.
- **`install.json` records a state, not a verb.** Per-file entries are `deployed` or
`kept-operator-modified`, never `add`/`change`/`unchanged`. Recording the run's verb made the
record differ between a first run and an identical second one, which rewrote the file on every
run and broke "a second run writes nothing" in the least visible way available. What later
commands need is whose copy is in the tree, and that does not change because time passed.
- **The `Bridge.cfg` decision compares against the last hash the installer *deployed*, not the last
hash it *saw*.** Once a file has been kept, the record's on-disk hash is the operator's content —
so a rule phrased as "is the tree still what the record last saw?" matches on the very next run
and overwrites exactly the file it had just protected. A keep has to stay kept for as long as the
edit is there; a live three-run test covers it, because the bug only appears from the second run
on.
- **A prior record is only consulted when it names this tree.** A host whose `install.json` points
at a different ServUO root — a shard moved or rebuilt beside the old one — is treated as having no
prior deployment, which errs toward keeping the operator's file.
- **The download is verified twice, for two different reasons.** The tarball's SHA256 is checked
against the bundle while it is being written (the trust anchor — these artifacts are unsigned);
then every extracted file is re-hashed against the release's own `manifest.json`, which catches a
truncated extraction and is what makes the hashes copied into `install.json` worth trusting. The
manifest's `protocol` and `version` are also cross-checked against the bundle, so an artifact that
disagrees with the matrix that named it stops the run before anything is written.
- **`RUNICGATEWAY_STATE_DIR` relocates the installer's own state**, so a run can be tested without
root. Documented in `--help` rather than hidden: an undocumented variable that moves where a tool
writes is worse than a documented one, and `doctor` must honour the same value to find what
`install` wrote.
Verified on this machine against a real ServUO 57.4 tree (`--verify`, which reported the tree's
`Bridge.cfg` as operator-owned and 23 code files as changed) and end to end into a scratch tree:
24 files deployed, a second run reporting `unchanged` and leaving `install.json` untouched, an
edited `Bridge.cfg` kept across three further runs while a hand-edited `.cs` was overwritten each
time, a pinned `--bundle`, a missing bundle tag, and a refusal — pid and path named, exit 1 — with a
process running out of the tree.
### Phase 2 — uo-link install and service
- Linux: binary → `/usr/bin/runicgateway-link`, config → `/etc/runicgateway/sidecar.toml`, db →
@@ -395,11 +606,171 @@ Repo work that must land before an installer can exist.
That both writes the config the service will read and returns the token to print, so the service
never starts against a config that does not exist yet.
**As built** ([installer#5](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/5)) —
`src/sidecar.rs` (binary, config, handoff) and `src/service.rs` (systemd, Windows SCM), wired into
the same `install` run. The decisions that were not already settled above:
- **Both platforms run the sidecar as a dedicated unprivileged identity.** Linux gets the system
user this section already specified; Windows gets a **virtual service account**
(`sc create … obj= "NT SERVICE\RunicGatewayLink"`), which the SCM creates itself and which has no
password. Plain `sc create` would have run it as `LocalSystem` — the most privileged local
identity there is, for a process that listens on two TCP ports while its Linux twin deliberately
does not run as root. The account only exists *after* `sc create`, which fixes the order of the
file permissions below.
- **`sidecar.toml` is locked down, because it holds the token.** Neither default location protects
it: `/etc` is world-readable and `%ProgramData%` grants `Users` read by inheritance, so an
unprivileged local account could read the shard's auth token out of a stock install. Linux gets
`chmod 600` plus `chown` to the service user; Windows gets `icacls /inheritance:r` down to SYSTEM
and Administrators **before** registration, then a read grant for the service account after it
exists. The database directory gets a separate write grant, since SQLite writes journal and WAL
files beside the database.
- **`--verify` runs no part of the sidecar half.** `--print-config` provisions — it writes the
config and mints a token — so a dry run that called it would create exactly the state it claims
not to. A `--verify` run reports what would be installed, reads no token, and prints no handoff.
It also **carries the existing `link` section of `install.json` through untouched**, so a dry run
on an installed host cannot make its service disappear from the record.
- **The installed binary's protocol version is checked against the bundle, and a mismatch stops the
run before the service is registered.** Gate 1 (§7.1) read that number from source at the release
tag; this is the same check applied to the binary that will actually answer the website. The
binary is left on disk — harmless without a service — rather than the run pretending to succeed.
- **`RUNICGATEWAY_STATE_DIR` now relocates the sidecar binary too, and suppresses service
registration.** Phase 1 left the binary path alone because nothing wrote it. A relocated run that
still dropped a binary into `/usr/bin` and registered a system service would be exactly the
half-in-the-real-system accident the variable exists to avoid — and there is no such thing as a
relocated systemd unit or Windows service. Such a run also leaves file permissions alone, because
hardening a scratch config against the only account that will ever read it just breaks the next
test run.
- **A host the installer cannot drive gets the recipe, not a failure or a weaker service.** No
systemd (`/run/systemd/system` absent — the correct test, since `systemctl` is present in plenty
of containers where PID 1 is not systemd), or a service user that cannot be created: the binary
and config are still installed, `install.json` records `service: null`, and the run prints the
exact unit text and commands. There is **no fallback to `User=root` or `LocalSystem`** — a service
quietly running with more privilege than its own documentation promises is worse than one that was
not registered. The printed Windows recipe states plainly whether the run locked the config down
or the operator still has to.
- **`install.json` never records the token.** The `link` section holds versions, the binary's hash,
the config and database paths, and the service's name, unit path and account. The token goes to
the terminal and to `sidecar.toml`, and the record is a support artifact people paste into bug
reports.
- **The service is stopped before its binary is replaced, and restarted rather than started
afterwards.** On Windows the file is locked while the service runs (and `sc stop` returns as soon
as the stop is *pending*, so the stop is polled, not slept on); on Linux the replacement is
permitted but leaves the old code serving until something restarts it. `systemctl start` on an
active unit is a no-op, which is precisely the wrong outcome after a replacement.
Verified on this machine end to end against a relocated layout: the bundle's Windows sidecar
downloaded and checksum-verified, `--print-config` provisioning a fresh config and returning a
token, the §6 handoff printed with the URLs composed from the host rather than the bind address, a
second run reporting `unchanged` / `already present` and leaving `install.json` byte-identical, a
`--verify` run over an installed host writing nothing and preserving the `link` section, and a
tampered binary detected by hash and replaced with no stray staging file left behind.
**Service registration itself stayed unverified until Phase 4** — a relocated 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, installing into `/usr/bin`,
`/etc/runicgateway` and `/var/lib/runicgateway` as root: the unit is written and `enable`d, the
service comes up `active, enabled` as the unprivileged `runicgateway` user, `sidecar.toml` lands
`600` owned by it, the database is created under `/var/lib` (so the `UOLINK_DB_PATH` pin works),
`/health` answers protocol 3, and `uninstall` takes the service, the unit, the binary and the
account away again while leaving `sidecar.toml`, the database and every overlay file in the ServUO
tree untouched.
Doing that found one bug that only a real service host could show, fixed in
[installer#8](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/8): **`user_created` has
to be sticky.** `service::prepare` answers "did *this run* create the account", which is false from
the second run on, so recording it verbatim made the field describe the run rather than the state —
the same class as Phase 1's two live-run bugs. It rewrote `install.json` on an identical re-run,
and it made `uninstall` (which removes only an account it created) silently leave behind the very
user this tool had added. The record now inherits `true` from a prior record naming the same
account, and only that one. Windows never showed it because the SCM's virtual account is not
something the installer creates.
**Still unverified: the Windows SCM half.** `sc create` demands elevation, and this machine's
automation runs unelevated; the systemd half above is the platform that could be driven end to end.
### Phase 3 — patch tier (opt-in)
Everything in §2.2. Detect applicability, dry-run, apply, record, warn about the core rebuild, and
degrade loudly rather than silently.
The rung ladder of §2.2.1 is the bulk of the work here: parse each `.patch` into hunks, reconstruct
each hunk's pre- and post-image blocks, and resolve the file through rungs 0–3 before writing
anything. The unsupported-version path (§2.2.2) is part of this phase, not a later polish — the
banner, the defaulted-to-no prompt, the `--patches-unsupported-servuo` flag, and the unsupported
marker carried into `install.json`, `doctor` and the uninstall report. Two pieces carry the risk and want direct tests — the hunk parser (headers, `\ No newline
at end of file`, CRLF files) and the uniqueness rule (a region that appears twice must fail, not
pick the first). Fixtures are cheap: the three stock 57.4 files, each with a hand edit far from the
patched region (must reach rung 2), an edit inside it (must reach rung 3), and an already-patched
copy (must reach rung 0).
**As built** ([installer#6](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/6), with the
metadata half in [servuo-plugins#10](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/10))
— `src/diff.rs` (the parser), `src/patch.rs` (the ladder and the applier) and `src/tier.rs` (consent,
writing, reporting, recording), wired into the same `install` run between the overlay sync and the
sidecar. The decisions that were not already settled above:
- **The engine is fully native; `git` is never invoked.** §2.2.1 wrote rung 1 as "apply verbatim
with `git apply`", but §1 chose the release tarball precisely so there would be **no git on the
shard host**, and rung 2 needs a native applier regardless. One engine now serves both: rung 1
keeps its distinct, stronger verdict — the whole file reproduced the diff's `index` pre-image,
computed as a git blob SHA1 in process — while the write goes through rung 2's code path. That
leaves one set of CRLF and whitespace behaviours to reason about instead of two, and a bug report
never has to say which engine ran. It is also not academic: the shipped `.patch` files are CRLF in
a Windows checkout while two of their three targets are LF, so `git apply` **refuses** patches
this places correctly.
- **What a `.patch` cannot say is declared by the release, with a built-in fallback.** Which patches
form one all-or-nothing unit, which companion `.cs` follows which, whether a **core** rebuild is
needed, and what declining costs are all things a diff does not carry. `servuo-plugins/patches/tier.json`
declares them and the release workflow folds them into `manifest.json` as `patch_tier` (§7.0), so
adding a patch regenerates release metadata rather than requiring an installer release — the same
rule §7.1 applies to the bundle. Overlay `v0.1.1` is in the current bundle and declares nothing,
so the installer carries a built-in description of exactly that release; a declared tier always
wins. A checked-in fixture of the release workflow's **own jq output** asserts the two descriptions
are identical, so the two repos cannot drift apart quietly — the failure mode otherwise is a tier
that is silently never offered.
- **All-or-nothing gained a second level.** §2.2.1 makes it per *patch file*; the tier is also
all-or-nothing per **feature**, because the two vendor-sale patches are one unit — `EventSink.cs`
grows the event, `PlayerVendorGumps.cs` raises it, and the companion subscribes to it. Applying
either alone yields a tree that does not compile or silently never emits. A patch that could have
been placed but was held back by a sibling says so in as many words; reporting it as applied is
the exact misreading this tier exists to prevent.
- **The pre-image of every patched file is cached**, under `<state>/patches/originals/`, mirroring
its path in the ServUO tree. The tier is the only part of the installer that edits a file the
operator owns, and this is what turns "here are the hunks we added" into a revert anyone can
verify — which matters most for a `region-match` apply, where the surrounding file was already
theirs. It lives in the state directory rather than beside the file it copies, because an
installer-owned file inside the ServUO tree is one `uninstall` has promised never to clean up. It
is written before the first edit and never overwritten, so it stays pre-tier however many times
`install` runs.
- **Every patch the tier *evaluated* is cached, not only the ones that applied** — a refinement of
§2.2's "cache the applied `.patch` files". The refusal message names that path as the file to
apply by hand (as §4 of `INSTALL.md` already illustrated), so caching only successes would point
an operator at a file the run had decided not to write.
- **Rung 0 reuses the previous record whole rather than re-deriving it.** The rung is the
support-relevant fact — how did this land? — and a later run re-deriving it answers
`already-present` for something that first landed as `region-match`. That flip rewrites
`install.json` on the second run of an identical install, which is the same class of bug as the
`Bridge.cfg` comparison in Phase 1: a record describing the run instead of the state. A tree
patched by hand per `INSTALL.md` Appendix A2 has no prior record, so there `already-present` is
correctly what gets minted.
- **Declining never erases what an earlier run applied**, and no longer claims a loss that is not
real. `--no-patches` and an unselected prompt both carry the previous `patches` section through
untouched, as `--verify` does — and the "Without it:" line now names only the features the record
does not already show as applied.
- **Withholding `--patches-unsupported-servuo` skips the tier loudly rather than failing the run.**
By that point the overlay is deployed and the sidecar is about to be installed; turning a completed
base install into exit 1 over a tier documented as optional would cost the operator more than the
tier is worth. Saying nothing would be the real failure, so it is reported where it happens.
Verified on this machine against the ServUO 57.4 tree at `C:\Users\colby\Desktop\ServUO`, across
four scratch roots built from its real files: a hand-patched tree (rung 0 on both vendor-sale
patches), a reverse-applied stock one (**rung 1 on the real `EventSink.cs`**, whose blob hash
reproduces the patch's declared `index d30788f` pre-image), a feature resolving at mixed rungs, a
tree with edits inside two patched regions (rung 3 — nothing written, the placeable sibling held
back, no companions copied, and all three patches cached anyway), and a non-57.4 tree both with and
without the extra consent flag. Three consecutive runs left `install.json` byte-identical, the
patched files unchanged, and the cached pre-image still pre-patch.
### Phase 4 — diagnostics and updates
`runicgateway doctor` — the command that makes the whole thing supportable:
@@ -407,7 +778,7 @@ degrade loudly rather than silently.
```
✓ ServUO found /opt/ServUO (57.4)
✓ Overlay in sync 24 files, all hashes match install.json
⚠ Patch tier 1 of 3 applied — vendor.sale unavailable
⚠ Patch tier 1 of 3 applied (region-match) — vendor.sale unavailable
✓ uo-link installed 1.1.0
✓ Service running, enabled
✓ Sidecar reachable 127.0.0.1:8080 /health ok
@@ -442,19 +813,246 @@ a clever automatic revert risks silently eating their work. It removes and it re
| Action | Scope |
|---|---|
| Removed | uo-link binary, its service entry (systemd unit / Windows service), `install.json` and the cached patch set |
| Kept | `sidecar.toml` and `uo-link.db` (config and history survive; `--purge` to drop them) |
| Removed | uo-link binary, its service entry (systemd unit / Windows service), `install.json` |
| Kept | `sidecar.toml`, `uo-link.db`, the cached patch set with its pre-patch originals, and (from Phase 5) the backups an upgrade took — `--purge` to drop them |
| **Printed, not done** | Every overlay file deployed into the ServUO tree, listed by path, for the operator to delete |
| **Printed, not done** | The exact hunks each applied patch added to `EventSink.cs`, `PlayerVendorGumps.cs`, `Logging.cs`, rendered from the cached `.patch` files, for the operator to revert by hand |
| **Printed, not done** | The exact hunks each applied patch added to `EventSink.cs`, `PlayerVendorGumps.cs`, `Logging.cs`, rendered from the cached `.patch` files — with the rung that applied each one (§2.2.1), since a `region-match` apply means the surrounding file was already the operator's — for them to revert by hand |
The printed report is also written to a file, so it survives the terminal scrollback of a long
uninstall.
**As built** ([installer#7](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/7)) —
`src/doctor.rs`, `src/update.rs` and `src/uninstall.rs`, plus `service::observe`/`service::remove`
and a `Mode` on the install pipeline. The decisions that were not already settled above:
- **`update` is the `install` pipeline in a different mode, not a second implementation.** This
section describes it as "re-resolve the bundle, then move both components to it" — which is what
an `install` over an existing deployment already does, down to keeping a modified `Bridge.cfg`
and restarting the service after replacing its binary. A separate implementation would have given
the sync rules, the two protocol cross-checks and the record-carrying logic a second place to
disagree. What actually differs is four things: a prior record is **required** (an `update` on an
uninstalled host is a typo or a state directory the run cannot see — never a first install under
a verb that promises to preserve), the tree comes from that record rather than from detection (a
host with two shards must not have an update silently move to the other one), the tier's scope
narrows, and the close is a diff instead of a handoff.
- **`update` does not reprint the token, and does call out a protocol change.** The token has not
changed and the website already holds it; reprinting a secret nobody has to act on just puts it
in another scrollback. The protocol number is the one thing an update *can* change that the
website has to be told about — a stale value in Admin → Shard is answered `409` and looks to an
operator exactly like the shard going offline.
- **The tier under `update` re-resolves only what an earlier run applied, without asking again.**
Not a fresh offer: a shard that declined stays unpatched through every update, which is what
opt-in has to mean. Consent is not re-sought for what is already in the tree — including on an
unsupported ServUO, where `install` demands a second flag — because the record *is* the evidence
that the operator opted in, and re-prompting would make an unattended update impossible on
precisely the hosts that most need their patches re-checked when an overlay moves. New features
the release offers are named but not applied; `--patches` is how they are taken up. A feature the
record shows as applied that the release no longer declares keeps its record rather than being
dropped: its edits are still in the tree, and a record that forgot them would stop `uninstall`
printing hunks that are really there.
- **`doctor` asks the thing itself, and asks it the way the service does.** `--print-config` is run
under the same `UOLINK_DB_PATH` the unit pins, so the config and database it names are the ones
the *service* opens rather than the ones the binary would pick on its own — which is what §5's
sketch promised and a bare call would have got wrong on Linux. It is also run **only when the
config already exists**, because that flag provisions: a diagnosis must not create the state it
is reporting on.
- **`doctor` exits `1` when a row failed, and a `⚠` never causes that.** The rule makes it readable
from a monitoring script, and the split is what keeps the report worth reading: a stopped shard
is a `⚠` with the reason ("you have not started it"), while a *running* shard that has not dialed
in is the `✗` (§2.1's silent failure). Being offline is a `⚠` too — a shard host with no route to
Gitea is a supported way to run this, and failing a health check over it would report a working
deployment as broken. Both network calls take short timeouts for the same reason.
- **The patch row re-resolves each recorded patch against the tree.** The cached `.patch` makes it
possible offline, and the expected answer is rung 0. A core upgrade, a hand revert or a restored
backup silently removes the tier's edits, and nothing else in the report would notice.
- **The cached patch set and `patches/originals/` survive an uninstall** — a deviation from the
table above, which listed them as removed. The report that same command prints tells the operator
to diff their stock files against those originals; deleting them would have made the advice
impossible to follow within one command's output. They are the only offline record of what the
tier changed once the release tarball is gone, so `--purge` is what removes them, alongside the
config and the database. The report names every path it left behind.
- **`--yes` means yes on `uninstall`, not "take the default".** Everywhere else that flag answers an
offer the *run* made, so taking the safe default is right. Here the operator typed the destructive
verb; reading `--yes` as "no" would leave an unattended uninstall unable to express itself at all,
and a script that appears to succeed while removing nothing is the worse of the two failures. The
interactive prompt still defaults to **no**, after listing exactly what will and will not be
touched.
- **`uninstall` exits `1` for a step it could not carry out**, having done everything else. The
common case is a binary still locked by a sidecar somebody started by hand, so a permission error
on that file says so rather than sending the operator to look at ACLs. The Linux service account
is removed only when the record says this installer created it; Windows' virtual account goes with
the service.
- **The overlay listing flags files edited since deployment.** An operator deleting that list file
by file must not lose their own `Bridge.cfg` settings or a script edit without being told which
ones those are.
Verified on this machine against a scratch ServUO 57.4 tree built from the real files: a healthy
`doctor` (exit 0), one against a tree with a deleted overlay file, an edited one and a reverted
patch (all three found, exit 1), an `update --verify` that wrote nothing, a real `update` that
repaired all three and left `install.json` byte-identical, `uninstall` with and without `--purge`,
a second `uninstall`, a locked binary reported as a problem with exit 1, and `doctor`/`update` on a
host with no record. `fmt`/`clippy -D warnings`/tests were run for Linux in Docker as well as on the
Windows host, since only half of `service.rs` compiles on either.
### Phase 5 — packaging polish
`.deb` packaging, Windows MSI, arm64 cross build, and optional automated backup before upgrade.
Deliberately last: v1 can register services directly (`sc create` / a written systemd unit) and ship
plain binaries. Nothing in Phases 1–4 should have to change to add these.
The last work before the first release. It was sketched as four items — `.deb` packaging, Windows
MSI, arm64 cross build, optional automated backup before upgrade — and the org lead settled its scope
on 2026-08-05: **two of the four are dropped rather than deferred**, because what stops them is an
ownership conflict that does not improve with time, and two are built.
**It ran 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* (§3), so shipping first would have meant a first release superseded by the next one, and
operators who downloaded a bare binary being told to re-download a package. Deferring the cutover
cost nothing: nothing was published from `edge`, and the guide's Appendix A was the path meanwhile
(and remains supported now that it is no longer the default).
| Item | Decision | State |
|---|---|---|
| `.deb` for uo-link | **Dropped** (§5.1) | — |
| Windows MSI | **Dropped** (§5.1) | — |
| arm64 cross build | **Build** — Linux `aarch64`, both components (§5.2) | ✅ Merged — [installer#9](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/9), [link#26](https://gitea.whitlocktech.com/RunicGateway/link/pulls/26), [installer#10](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/10) |
| Backup before upgrade | **Build** — on by default, scoped to what a run overwrites (§5.3) | ✅ Merged — [installer#11](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/11) |
| Docs the release invalidates | **Do** (§5.4) | ✅ Done — `INSTALL.md` took the `aarch64` and backup content pre-cutover; the status rewrites landed after it |
#### 5.1 What ships is plain binaries: no `.deb`, no MSI
Both were proposed before Phases 2 and 4 existed. They now collide with the components those phases
made the installer own, and the collision is the deciding argument in each case.
- **A `.deb` under link's release would own `/usr/bin/runicgateway-link`, the systemd unit and the
service user** — the same three things `service.rs` writes, hardens and removes, and that
`install.json` records so `doctor` and `uninstall` can reason about them. Two owners for one file
is not a packaging detail: `uninstall` deleting a dpkg-owned binary leaves the package *installed*
and broken, an `apt upgrade` replacing that binary makes `doctor` report drift no operator caused,
and the unit text would live in two repos free to disagree about the account it runs as. The
variant that avoids all of that — a binary-only `.deb`, no unit, no user — buys apt-managed
upgrades of one file, which `update` already does from a **protocol-checked** bundle (§7.1). That
is the stronger of the two guarantees, so the package would be trading correctness for
familiarity.
- **An MSI contradicts a decision already locked**: the installer does not install itself (Phase 0.4
as built). It would also add a second uninstall path — Add/Remove Programs — beside the
`uninstall` verb, which owns state an MSI cannot see: `install.json`, the cached patch set and its
pre-images, and the ServUO-tree report that is printed rather than done (Phase 4). Unsigned, it
raises the same SmartScreen prompt a bare `.exe` does (§3), so it does not even buy the dialog it
looks like it should.
So §3's release layout loses its `.deb` line and gains no installer package. Nothing in Phases 1–4
changes — this item is a removal, which is why settling it is cheap and shipping it first would not
have been.
**What would reopen it.** If operators ask for `apt`- or `winget`-managed installs, the thing to
package is the sidecar as a standalone service *with the installer taught to detect and defer to a
package-managed one*. That is a design change to who owns the service, not packaging polish, and it
is out of scope here.
#### 5.2 arm64 — Linux `aarch64`, both components
§2.6 recorded arm64 as "not buildable today", which was true of the *workflows* rather than of Rust:
`link/release.yml` and `installer/release.yml` cross-compile `x86_64` Linux and Windows only, so
`platform_key()` (`src/bundle.rs`) refuses every other host by name. Ampere/Graviton instances and
Pi-class boxes are a realistic ServUO home, and the installer's dependency set was already chosen to
avoid OpenSSL and any C toolchain of its own (Phase 1 as built) — which is what makes this two build
steps per workflow rather than a toolchain project.
**Both components or neither.** An installer that runs on `aarch64` but resolves a bundle carrying no
`aarch64` sidecar has moved the failure later, not fixed it. Windows-on-arm and macOS stay unbuilt:
no supported MinGW target here for the first, and no shard host is the second.
**The order is forced by the bundle CI, which is strict on purpose** (Phase 0.3 as built): an
unrecognized `link` asset name is a hard failure, and `linux-x86_64` + `windows-x86_64` are asserted
present. So a link release carrying a new binary reddens the compose job unless it is taught the name
first — and requiring the name before link publishes it fails *every* bundle for as long as the gap
lasts. Hence:
| Step | Repo / branch | Change | Why it is this one first |
|---|---|---|---|
| 1 | `installer` `main` | `bundle.yml` maps `*-linux-aarch64` → `linux-aarch64`, and **does not** add it to the required list | The compose job runs from `main`; teaching it the name first means link's next release composes instead of failing |
| 2 | `link` `main` | `release.yml` adds `aarch64-unknown-linux-gnu`, cross-linked with `gcc-aarch64-linux-gnu` | Publishes the first `aarch64` sidecar; the nightly cron folds it into a bundle |
| 3 | `installer` `main` | `linux-aarch64` joins the required list | Only safe once a release actually carries it — from here a dropped target reddens CI instead of silently vanishing from every bundle |
| 4 | `installer` `edge` | `release.yml` adds the same target; `platform_key()` learns `("linux", "aarch64")` | The installer binary itself, on the branch that carries the crate |
`bundle.yml` is therefore edited on `main` only and never on `edge`, so the cutover merge has nothing
to conflict over.
**What building it found.** Neither crate cross-compiles with the arm64 *compiler* alone.
`gcc-aarch64-linux-gnu` only **recommends** `libc6-dev-arm64-cross`, and both release workflows
install with `--no-install-recommends` — so the C that each crate pulls in (bundled SQLite under
`sqlx` for the sidecar, `ring` under `ureq`'s rustls for the installer) dies on a missing
`bits/libc-header-start.h` while every Rust dependency builds fine. Both halves were reproduced in a
`rust:1-slim-bookworm` container, failure then fix, before either workflow was written.
#### 5.3 Backup before overwrite
**Scoped by what cannot be fetched again.** Not the sidecar binary or the overlay files — both are
re-downloadable and hash-named in the bundle. Not the database either: `link/sidecar/src/store.rs`
creates every table `IF NOT EXISTS` and every one of them holds shard state the sweeps repopulate, so
it is a cache with a schema rather than a record. What an `update` can destroy irrecoverably is:
1. **An operator's own edits to a deployed `.cs` file.** Phase 1 overwrites those unconditionally and
by design — `Bridge.cfg` is the single exception — so the one place the tool knowingly discards
work is the one place it should keep a copy.
2. **`sidecar.toml`**, whose token the website already holds. Mint a new one and the site's saved
config starts answering `409`/`401` with nothing on the sidecar to explain why (§2.4).
So the rule is **copy what this run is about to overwrite, plus `sidecar.toml`** — not a snapshot of
everything. A snapshot of all 24 overlay files would be mostly byte-identical to a release tarball
that is still downloadable, and the noise would bury the two files that matter.
- **Where:** `<state>/backups/<utc-timestamp>/`, each file mirroring its path in the tree it came
from, beside a `manifest.json` naming the source path, its SHA256, and the bundle moved from and
to. In the state directory, not the ServUO tree — the same rule the tier's `patches/originals/`
follows, and for the same reason: `uninstall` has promised never to clean up in there.
- **When: whenever the run is about to overwrite something**, whichever verb was typed. This
section first said "`update`, and `install` over an existing record", justified by a first install
overwriting nothing — **which is wrong**, and building it is what showed that. A tree deployed by
hand per [`INSTALL.md`](INSTALL.md) Appendix A2 — the path this project recommends while the
binary is unreleased — has `.cs` files the first `install` plans as `Change` and overwrites, with
no prior record anywhere to notice. The direct test covers that case, and a genuine first install
onto a clean tree still writes nothing, because there is nothing to copy. `--verify` writes none
either, for the reason it runs no part of the sidecar half (Phase 2): a dry run must not create
state.
- **`sidecar.toml` joins a backup that is already being taken; it is never the reason for one.**
Nothing in the installer rewrites it, so treating it as a trigger would leave a dated directory
behind after every no-op `update` — the empty-backup problem one step along. It is copied so that
a restored set of files arrives with the token that matches it.
- **`--no-backup`** opts out, for an operator with their own snapshotting.
- **Retention is three.** Older ones are pruned as new ones are written; an unbounded directory of
ServUO source copies on a shard host is its own support problem. `uninstall` keeps them and
`--purge` drops them, alongside the config, the database and the cached patch set — same rule as
Phase 4, and the same reason: they are the only offline record of what was there before.
- **The directory is created lazily and the manifest is written last**, so a directory carrying one
is a *complete* backup. Listing and pruning consider only those: a run interrupted mid-copy must
neither be mistaken for a backup nor be able to evict a good one by being newer than it. It is
left on disk for a human to look at rather than silently deleted.
- **Restore is printed, not done.** Consistent with the uninstall report, and for the identical
reason: the installer cannot know what has changed since, and a clever automatic restore over a
newer overlay eats work rather than saving it. `doctor` names the most recent backup and its
timestamp, so an operator asking "can I go back" does not have to know the layout to answer.
#### 5.4 Documentation the release layout invalidates
Not optional, and grouped here because a first release is the moment these are read for the first
time by someone who was not in the room:
- **`installer/README.md`'s status table** — the first thing a visitor to the repo reads, and it
described a tool that was neither finished nor released. Rewritten at the cutover.
- **`INSTALL.md`** gained the `aarch64` download lines, the backup behaviour and `--no-backup`
before the cutover, and its status banner afterwards: the installer is the path the guide leads
with, Appendix A the supported manual one.
- **This file:** §3 lost the `.deb` and gained the `aarch64` assets, §2.6's arm64 bullet is now
historical, and §7.1's example bundle grew the third asset key.
#### Cutover entry criteria — met, 2026-08-07
Both gates were satisfied before
[installer#17](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/17) merged: this phase,
and the **Windows SCM verification** (see the status header and
[Phase 2 as built](#phase-2--uo-link-install-and-service)). The second was not busywork — it failed
on its first real run with 1053 and needed a sidecar fix before it passed, exactly as running the
systemd half for real had turned up a bug unit tests could not. The bundle the release shipped
against was `2026.08.07`: link `v1.2.1`, overlay `v0.2.0`, both declaring protocol 3.
---
@@ -514,14 +1112,30 @@ Shipped inside every `runicgateway-overlay-<ver>.tar.gz`, generated by that repo
"repo": "RunicGateway/servuo-plugins",
"protocol": 3,
"servuo": { "min_version": "57.4", "patches_verified_against": "57.4" },
"patch_tier": {
"features": [{
"name": "vendor-sale",
"summary": "vendor.sale events — player-vendor purchases with buyer, owner, item, price and commission",
"lost": "no vendor.sale events",
"rebuild": "core",
"patches": [
{ "name": "playervendor-sale-eventsink", "file": "patches/playervendor-sale-eventsink.patch", "target": "Server/EventSink.cs" },
{ "name": "playervendor-sale-gump", "file": "patches/playervendor-sale-gump.patch", "target": "Scripts/Gumps/PlayerVendorGumps.cs" }
],
"companions": [
{ "file": "patches/BridgeVendorSale.cs", "install_to": "Scripts/Custom/Bridge/BridgeVendorSale.cs" }
]
}]
},
"files": { "overlay/Config/Bridge.cfg": "32718424…", "patches/…": "…" }
}
```
`version` and `commit` come from the release engine; `protocol` and the `servuo` block are read from
`servuo-plugins/overlay.toml`; `files` is a SHA256 per shipped file.
`servuo-plugins/overlay.toml`; `patch_tier` is folded in from `servuo-plugins/patches/tier.json`;
`files` is a SHA256 per shipped file.
Two of these carry weight beyond documentation:
Three of these carry weight beyond documentation:
- **`protocol` is a hand-maintained declaration, and has to be.** The plugin announces no version on
the wire and none is queryable before ServUO boots, so nothing in CI can derive it — which makes
@@ -532,6 +1146,18 @@ Two of these carry weight beyond documentation:
overlay moved on"** (§5, Phase 4). The installer copies these hashes into `install.json` at deploy
time; a later mismatch against *both* the manifest and `install.json` means upstream changed, a
mismatch against `install.json` alone means local edits.
- **`patch_tier` is everything a `.patch` cannot say about itself**, and is the reason the tier is
data rather than code. Which patches form one all-or-nothing unit, which companion `.cs` may only
be copied once that unit lands, whether the change needs a **core** solution rebuild or just the
dynamic script build, and what the operator loses by declining are none of them derivable from a
diff. Declaring them here means adding a patch regenerates release metadata rather than requiring
an installer release — the rule §7.1 already applies to the bundle. The maintainer-facing source
is `servuo-plugins/patches/tier.json`; the release workflow folds it in and removes the staged
copy, so the tarball carries exactly one statement of the table, and gates that every `.patch` is
described by exactly one feature, that every named patch and companion exists, and that each
declared `target` is the file its diff actually edits. Installers older than this key ignore it;
an installer newer than the overlay it is deploying falls back to a built-in description of the
release that predates it (see [Phase 3 as built](#phase-3--patch-tier-opt-in)).
`min_version` and `patches_verified_against` are separate on purpose. The base overlay only *adds*
files and is expected to work broadly; the patch tier diffs stock ServUO files and is verified
@@ -552,6 +1178,7 @@ resolving "latest", CI publishes a small manifest naming an exact, checked combi
"repo": "RunicGateway/link", "tag": "v1.1.0", "version": "1.1.0", "protocol": 3,
"assets": {
"linux-x86_64": { "name": "uo-link-sidecar-linux-x86_64", "url": "…", "sha256": "27d491ef…" },
"linux-aarch64": { "name": "uo-link-sidecar-linux-aarch64", "url": "…", "sha256": "…" },
"windows-x86_64": { "name": "uo-link-sidecar-windows-x86_64.exe", "url": "…", "sha256": "fbefd886…" }
}
},
@@ -566,7 +1193,8 @@ resolving "latest", CI publishes a small manifest naming an exact, checked combi
Note `link.assets` is a **map keyed by platform**, not the single `sha256` this section originally
sketched: link publishes a Linux binary and a Windows `.exe`, and the installer runs on both, so one
hash could only ever have described one of them. `schema` versions this document's shape and is
hash could only ever have described one of them. Phase 5's `linux-aarch64` (§5.2) is the first
addition that map was shaped for, and it costs the bundle nothing but a key. `schema` versions this document's shape and is
independent of `protocol` and of either component's release version — all three move separately.
The installer fetches the current bundle at run time; `--bundle <tag>` pins an older one for a
@@ -589,24 +1217,44 @@ Two gates run at compose time, both cheap and both worth it:
#### Where bundles are published
Committed to the installer repo under `bundles/`, so the installer's fetch is a plain anonymous
`GET` against a public repo — the shard host has no Gitea credentials (§1):
Committed to the installer repo, on a **`bundles` branch of their own** and at its root, so the
installer's fetch is a plain anonymous `GET` against a public repo — the shard host has no Gitea
credentials (§1):
```
bundles/current.json → …/RunicGateway/installer/raw/branch/main/bundles/current.json
bundles/bundle-<tag>.json → …/raw/branch/main/bundles/bundle-2026.08.04.json (--bundle)
current.json → …/RunicGateway/installer/raw/branch/bundles/current.json
bundle-<tag>.json → …/raw/branch/bundles/bundle-2026.08.04.json (--bundle)
```
Every bundle is kept forever, so `--bundle` stays reproducible. Tags are UTC dates; a second bundle
on the same day — a sidecar release in the morning and an overlay release in the afternoon is the
normal way that happens — becomes `2026.08.04.2`, so one tag always names exactly one matrix.
**A branch, not `main`, and that correction cost a day.** This section originally said bundles were
committed to `main` and "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 a push from CI (`pre-receive hook declined`), 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. So the assumption that a working push path already existed was never tested by
anything.
Publishing to a branch of its own keeps every property the original choice was for — a reviewable
diff, a git history of the compat matrix, plain anonymous raw URLs, no credentials on the shard host
— and needs no exception at all. The alternative, whitelisting a scheduled job for pushes to the
default branch, buys nothing this does not.
**Not one Gitea release per bundle**, which was the obvious alternative. This repo's own releases
are the installer *binaries*, and `/releases/latest` returns whichever release is newest regardless
of kind — interleaving bundle releases would make "latest" intermittently resolve to a release
carrying no installer binary. Committing also yields a reviewable diff and a git history of the
compat matrix, and needs no new branch-protection exception: `release.yml`'s version-bump commit
already requires the CI user to be able to push to `main`.
carrying no installer binary.
**The release workflows tag and never write to a branch**, for the same reason and settled at the
same time (org lead, 2026-08-05): the tag *is* the version, as `servuo-plugins` has always done it.
The version is still written into `Cargo.toml` before building — so a released binary self-reports
correctly — but is no longer committed back, and the next version is computed from the newest tag.
A first release must not depend on a write to a protected branch.
### 7.2 What triggers a bundle
@@ -665,18 +1313,51 @@ mismatched pair from being published as a bundle — which is the mechanism that
## 8. Open questions
1. **Windows service mechanism** — `sc create` against the plain console binary (simplest, works
today), a bundled WinSW/NSSM shim, or a native `--service` mode in the sidecar using the
`windows-service` crate (cleanest, but changes `link`). Recommendation: `sc create` for v1,
revisit if restart semantics prove inadequate.
2. **Does the installer manage ServUO stop/start?** Currently it refuses while ServUO runs and tells
1. **Does the installer manage ServUO stop/start?** Currently it refuses while ServUO runs and tells
the operator to restart afterward. Offering to stop/start would be friendlier but means owning
another shard's process lifecycle, and the shard's own start scripts vary.
3. **Co-location assumption** — the shard dials out to the sidecar on loopback `127.0.0.1:7788`, so
2. **Co-location assumption** — the shard dials out to the sidecar on loopback `127.0.0.1:7788`, so
sidecar and ServUO must share a host. Should the installer support installing only uo-link on a
different host, or hard-assume co-location?
Resolved and moved into §1 / §2.2 / §5: uninstall scope, and minimum ServUO version.
**Resolved — Windows service mechanism** (was question 1). Registration is `sc create` with
`obj= "NT SERVICE\RunicGatewayLink"` (plain `sc create` would run as `LocalSystem`, which the Linux
half pointedly does not do), `sc failure … actions= restart/5000` as the counterpart of systemd's
`Restart=on-failure` / `RestartSec=5`, and the config pinned in `binPath` rather than in a
machine-wide environment variable. No WinSW/NSSM shim: that would be a third binary to keep current.
**Corrected 2026-08-07 — the sidecar needs its own service mode after all.** This section previously
recorded that `sc create` against the *plain console binary* worked and needed no change to `link`.
It does not, and the first Windows run proved it: `sc start` failed with **1053** and the event log
read *"a timeout was reached (30000 milliseconds) while waiting for the … service to connect"*,
with `SERVICE_EXIT_CODE : 0` — the process had started fine and simply never spoke to the SCM.
The premise was a false symmetry with systemd. systemd supervises *any* foreground process; the
Windows SCM supervises only a process that calls `StartServiceCtrlDispatcher` within ~30 seconds and
then reports its own state transitions. There is no third option where `sc.exe` adopts an arbitrary
console executable — it is a service-aware binary or a shim, and the shim was already rejected.
So `link` gains a Windows service entry point (the `windows-service` crate, behind
`[target.'cfg(windows)'.dependencies]`). The objection that this puts Windows plumbing inside a
platform-agnostic component is answered by keeping it *only* at the edges: `app::run` is the whole
sidecar and is shared, while `windows.rs` and `unix.rs` do nothing but start it and tell it when to
stop. Nothing platform-specific reaches the shared path, and Cargo neither resolves nor builds the
Windows crates for Linux.
Consequences worth knowing:
- **One binary, no `--service` flag.** The dispatcher is tried first; failing with
`ERROR_FAILED_SERVICE_CONTROLLER_CONNECT` (1063) means "not started by the SCM" and falls through
to a normal foreground run. `cargo run` and a hand-run diagnostic are unchanged.
- **A service has no stdout**, so in service mode the sidecar logs to a daily-rolled file beside its
config instead of into the void.
- **`Running` is reported only once the shard port is bound and the store is open**, so a bad config
fails the *start* rather than flapping Running → Stopped, and a failed run leaves a nonzero
`SERVICE_EXIT_CODE` behind rather than the misleading `0` above.
- **A sidecar older than v1.2.0 can never start as a service on Windows**, however good its config.
The installer says so by name when it sees 1053.
**Resolved — branch targeting for the new repo** (was question 4). The v3 cutover landed:
`servuo-plugins#6` merged, so that repo's `main` and `edge` agree at protocol 3. The release
workflow targets `main`, and the installer repo starts clean on `main`. §7.4's caution still applies

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

@@ -25,13 +25,16 @@ link/
│ └── PULL_REQUEST_TEMPLATE.md
├── sidecar/
│ ├── src/
│ │ ├── app.rs
│ │ ├── cli.rs
│ │ ├── config.rs
│ │ ├── main.rs
│ │ ├── rpc.rs
│ │ ├── shard.rs
│ │ ├── store.rs
│ │ └── web.rs
│ │ ├── unix.rs
│ │ ├── web.rs
│ │ └── windows.rs
│ ├── .gitignore
│ ├── Cargo.lock
│ ├── Cargo.toml

View File

@@ -1,5 +1,12 @@
# uo-link
> **Historical snapshot**, from before the bridge was split into
> [`RunicGateway/link`](https://gitea.whitlocktech.com/RunicGateway/link) (sidecar) and
> [`RunicGateway/servuo-plugins`](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins)
> (plugin). Kept for the architecture notes below. **To set a shard up, use
> [installer/INSTALL.md](../installer/INSTALL.md)** — `deploy.ps1` as described here is a developer
> tool, not the operator path.
ServUO ⇄ Rust sidecar bridge. The shard emits newline-delimited JSON over a loopback TCP socket; the sidecar owns the WebSocket the website consumes.
```

View File

@@ -119,6 +119,20 @@ server/
vendors, chars, sales, houses
appeals.router.js (4) /player/appeals
shard.controller.js + appeals.controller.js
settings/ index.js owns the shared `noindex, requireAuth` gate
(authenticated, ANY role) and the mount table.
A fifth group, for site-wide settings that
need a login but no particular role — /public
is anonymous, /admin/settings is adminOnly
while AdminLayout renders for editors and
moderators, and /player is self-scoped data
nav.router.js (1) /settings/nav — the nav_admin and
nav_player overrides, read by the
layouts that render them
theme.router.js (1) /settings/theme/options — the closed
sets the admin appearance form is
built from. Static; no DB read
nav.controller.js + theme.controller.js
admin/ index.js mounts the capability routers below at their
own prefixes; owns the shared
`noindex, isLoggedIn, staffOnly` gate and
@@ -151,7 +165,15 @@ server/
email.router.js (6) /admin/email — Gmail OAuth2
delivery — 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
PUT /site-mode (adminOnly) — the
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`,
`contact_email` (=UOMysticmoon@gmail.com), `site_title`.
**Deliberately unseeded keys** — the theming & navigation overrides
(`theme_visual`, `brand_assets`, `nav_public`, `nav_admin`, `nav_player`). All
five are JSON strings, and **the absence of the row is the "use the default"
state**: colors/fonts/radii fall back to `theme.css`, assets to `BRAND_*`, navs
to the hardcoded `NAV` arrays. No migration writes defaults into them, because a
stored copy of a default would stop tracking the default. Resetting one is
therefore a `DELETE`, not a write — see `DELETABLE_KEYS` in `settings.model.js`
and [THEMING_AND_NAV.md](THEMING_AND_NAV.md) §2.
Values are `TEXT`, so a JSON-valued key arrives as a **string** and every
consumer parses it. Server side that is `utils/settingsJson.js`
(`parseJsonSetting`), client side `client/src/lib/settingsJson.js` and
`parseLayout`; both treat a malformed or wrong-shaped value as **absent** rather
than as an error, so a hand-edited row degrades to the default instead of
rendering something broken.
**The three `nav_*` rows are presentation, never authorization.** An entry is
keyed by an item's existing `to` and may carry only `label`, `order`, `hidden`
and — admin nav only — `group`; `utils/navOverrides.js` rejects anything else on
write, naming the key. It deliberately does **not** check that a `to` exists: the
base `NAV` arrays are client constants, and duplicating them server-side would
create a second source of truth for navigation that drifts the first time a route
is added. `client/src/lib/navOverrides.js` drops an unknown `to` at merge time
instead, which is also what makes deleting a route in code safe. The merge runs
*before* the role and shard-feature filters in `SiteHeader.jsx` /
`AdminLayout.jsx`, which are unchanged and remain the boundary — a stored
`hidden: false` on a gated item shows nobody anything. `hidden: false` is
accepted (the editor sends it mid-edit) but never stored, so hiding stays
subtractive. `hidden` on `/admin/navigation` is dropped for `nav_admin`, because
that screen is the only UI that can un-hide anything.
**`nav_public` may also carry dropdown sections and admin-authored links**, as
`{ items, sections, links }` — a bare map still reads as `items`, and a nav with
no sections still stores one. A **section** has a label and a position and no
route at all: it only opens, so it adds no reachable surface. A **link** is the
one place a path may be named that the code does not declare, and is therefore
the one place the path rule applies: same-origin only, no scheme and no
protocol-relative `//host`. A link carries no gate of its own and needs none —
the page behind it enforces its own access, so an added link advertises a route
and never grants one. Coded entries stay in `items`, keyed by a route the base
array must declare, which is what keeps "an override cannot introduce a route"
structurally true. Sections and links are dropped for `nav_admin` / `nav_player`,
whose layouts cannot render them.
**`theme_visual` is resolved server-side, not shipped raw to the browser.**
`utils/themeResolve.js` layers `:root` ← preset ← custom, field by field, into
the CSS custom properties `getPublic()` returns as `theme`; the SPA's only job
is to write them onto `<html>` and take back what it wrote last time
(`client/src/lib/themeVars.js`). One authority for the merge means the effective
accent in `brand.accent` — the cross-repo contract the Android app and the
Discord bot theme themselves from — always agrees with what the website paints.
Values reaching a CSS variable are checked against closed sets on both paths:
strictly on write (400, naming the field) and forgivingly on read (drop the bad
field, keep its neighbours).
### activity_log — append-only
| col | type | notes |
|---|---|---|
@@ -288,7 +365,7 @@ A DB read never yields a usable reset link. See §4 `/auth/password/*`.
| col | type | notes |
|---|---|---|
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | |
| stream_id | VARCHAR(64) NOT NULL | an id from the catalog (`config/notificationStreams.js`), validated on write |
| stream_id | VARCHAR(64) NOT NULL | an id from the catalog (`modules/registries.js` — core's plus every installed module's), validated on write |
| created_at | DATETIME | |
`PRIMARY KEY(user_id, stream_id)`. Subscriptions are per-user (applied to every device); a PUT
@@ -588,6 +665,42 @@ JSON. `resolveMany()` returns only ids that resolved to something displayable
`~1_val~` are stripped, since the bridge sends the id and never the property packet that carries the
arguments — and it never throws, because a cliloc lookup is decoration on a character sheet.
### installed_modules — what is installed, and what happened to it (module system)
One row per installed module, keyed by the `id` from its `module.json` — the same id that names its
directory on the modules volume and its URL segment.
| Column | Shape |
|---|---|
| `id` | VARCHAR(32) PK — the module id |
| `name`, `version` | the manifest's label and semver, for the admin Modules screen |
| `state` | ENUM `installed` / `enabled` / `disabled` / `started` / `startup_failed` |
| `failure_stage`, `failure_reason` | the stage a failure happened at (`manifest`, `core_api`, `mounts`, `extensions`, `schema`, `require`, `register`, `boot`) and its recorded reason |
| `source`, `sha256` | the release the bundle came from and the digest verified before unpacking; both NULL for a directory placed on the volume by hand |
| `installed_at`, `started_at`, `updated_at` | `started_at` is the last **successful** start |
**This table never decides which routes exist.** The module loader scans the filesystem at require
time, before the database is reachable, so the URL surface is a property of the volume — which is what
lets `routes.manifest.json` be generated against a dead database. A disabled module stays mounted and
is guarded; the row decides whether it *answers*, not whether it is there.
**Every boot resets each non-disabled row to `enabled`** and clears its recorded failure, then the load
writes that boot's outcome. So a `startup_failed` module is retried on the next restart (an operator
who fixes the cause needs no admin-panel visit), a running module can never display a stale reason,
and `disabled` — the one operator *decision* rather than outcome — survives untouched. A re-install or
upgrade refreshes the metadata and leaves `state` alone.
The write happens in one place, `src/modules/lifecycle.js`, on the boot path after `ensureSchema()`
and before the listener binds: it resets the last boot's outcomes, writes a row for every module found
on the volume (with NULL provenance for a hand-placed directory), marks any row whose directory is
**gone** `startup_failed`, and then runs each surviving module's `onBoot` and records what happened. A
`disabled` row is guarded, not booted, and never has its failure re-recorded — an outcome must not
overwrite the operator's decision. Every one of those writes is individually caught: a row that will
not update is worse reporting, never a failed boot.
Design of record: [`MODULE_SYSTEM.md`](MODULE_SYSTEM.md) §2.4; the loader's obligations are
[`MODULE_API.md`](MODULE_API.md) Part 4.
---
## 4. API contract
@@ -602,7 +715,7 @@ are authoritative, and they answer different questions:
| Artifact | Source of truth for | Generated by |
|---|---|---|
| `server/routes.manifest.json` — mirrored as [api-route-inventory.json](./api-route-inventory.json) | **What URLs exist.** 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 |
The split is deliberate: Swagger is annotation-derived, so an unannotated route is invisible in it and
@@ -688,14 +801,18 @@ registers device endpoints (`/auth/me/devices`); nothing is pushed unless subscr
self-hosted **ntfy** endpoint (`utils/pushDispatch`); the app wakes and pulls the real, ownership-
checked content over the authenticated API. Two producers fan out through the one publisher: the shard
ingest dispatcher (`utils/shardIngest`, beside the SSE broadcast) for shard-derived streams, and the
create/publish-post path for `news.post`. The stream catalog + event→stream mapping is
`config/notificationStreams.js`. Security invariants:
create/publish-post path for `news.post`. The catalog is assembled at boot by
`modules/registries.js` from core's own streams (`config/coreStreams.js` — just `news.post`) plus
each installed module's; the shard streams and their event→stream mapping are
`config/shardStreams.js`, which belongs to module-uo and moves out with it
(MODULE_SYSTEM.md §1.8). Security invariants:
- **Same public/admin split as the SSE feed.** Public streams are drawn *only* from the SSE
`PUBLIC_KINDS` allowlist; a sensitive kind (audit/cheat/IP/login-attempt) can never produce a public
push.
- **Personal streams are owner-keyed.** `vendor.sale` / `house.idoc` / `account.login` are delivered
only to the *owning* user's devices, resolved via `shardLinks` (the same ownership check as
`/player/shard/*`).
`/player/shard/*`) in `utils/shardPush.js`. `utils/pushDispatch.js` itself only publishes to a
stream id someone else resolved — it has no idea what a shard event is.
- **SSRF guard.** A device `endpoint` is a client-supplied URL the server POSTs to, so registration and
every publish validate it is HTTPS, non-private/loopback, and (when configured) on the shard's ntfy
allow-set (`NTFY_BASE_URL` / `NTFY_ALLOWED_ORIGINS`).
@@ -778,7 +895,7 @@ from the per-route **siteMode** middleware (§5), never from an auth gate.
| Method | Path | Notes |
|---|---|---|
| GET | `/settings` | whitelisted public keys, derived `registration`/`gameAccountSignup` flags, the per-shard **`brand`** block (name, `accent` color, logo/hero/favicon) a client themes itself from — one image runs as any shard, asset fields may be site-relative paths (resolve against the base URL) — 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 | `/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 |
@@ -802,6 +919,18 @@ from the per-route **siteMode** middleware (§5), never from an auth gate.
Public content GETs pass through the **siteMode** gate (§5).
### /settings (settings/index.js → §2) — behind `requireAuth` + `noindex`, no role gate
Site-wide settings that need a login but no particular role. It exists because the
other four groups each answer a different question: `/public` is anonymous,
`/admin/settings` is `adminOnly`, and `/player` is data scoped to `req.user.id`.
These rows are configuration that happens to need a login.
| Method | Path | Purpose |
|---|---|---|
| GET | `/settings/nav` | `{ nav_admin, nav_player }` — the stored nav overrides as raw JSON strings (or `null`), for the two authenticated layouts that render them. Deliberately not public: an anonymous visitor has no use for either, and the admin nav's labels describe the shape of the admin surface. Open to **any** role because `AdminLayout` renders for editors and moderators and `PlayerPortalLayout` for players, none of whom can read `GET /admin/settings`. Presentation-only — the role/feature filters in those layouts still decide what is shown, and an override can never un-hide a gated item (see [THEMING_AND_NAV.md](THEMING_AND_NAV.md) §7) |
| GET | `/settings/theme/options` | The closed sets an admin may pick from when theming the site: the presets (each with its **full token map**, so a form can show what an unset field currently resolves to), the curated Google Fonts shortlist per role, the shadow depths, the editable color/radius field names paired with the CSS variable each drives, and `shippedTokens` (what `theme.css`'s `:root` declares). Static — derived from `config/themePresets.js`, no DB read. Served rather than duplicated in client code so the options the form **offers** can never drift from the ones `PUT /admin/settings` **accepts** |
### /admin (admin/index.js → the capability routers in §2) — all behind `isLoggedIn` + `noindex` + `staffOnly`
`admin/index.js` applies the shared gate and mounts each capability router at the prefix it owns;
@@ -836,7 +965,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 |
| GET | `/wiki` · GET `/wiki/:slug` | read incl. unpublished |
| POST | `/wiki` · PUT `/wiki/:slug` · DELETE `/wiki/:slug` | manage pages |
| GET | `/settings` · PUT `/settings` | read all / update `{key:value,...}` |
| 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 | `/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) |
@@ -852,6 +983,42 @@ file a route sits in — that is the property the route manifest freezes.
Every admin write logs to `activity_log`.
### The SPA HTML shell (`app.js` → `utils/htmlShell.js`)
The SPA catch-all serves `client/dist/index.html` with this instance's branding templated into the
`<head>` — title, meta description, Open Graph / Twitter tags, `<link rel="icon">` — so one prebuilt
image serves per-instance metadata to a crawler that never runs the JavaScript.
That used to be a single render at module load, from `BRAND_*` env only. It cannot be, now that the
favicon and OG image can come from the admin's `brand_assets` row: the shell depends on state that
changes while the process runs. `utils/htmlShell.js` owns the lifecycle, and three properties are
deliberate:
- **A cached string in the steady state.** The shell is rendered lazily on first request and reused;
a settings read per page view would put the database on the critical path of every SPA route,
including during an outage where the API is already degraded. Concurrent first requests share one
render.
- **A DB fault never fails the page.** A failed read renders the env-only shell — exactly the
pre-feature behavior — and that result is cached like any other, so an outage does not become a
failing query per page view.
- **Byte-identical with no rows.** An instance that has never been themed and has uploaded nothing
gets the same bytes it got before the feature existed. Locked by `test/htmlShell.test.js`, which
keeps a verbatim copy of the old renderer as its reference.
Invalidation is explicit — the settings controller calls `htmlShell.invalidate()` after a successful
write to `brand_assets` or `theme_visual` — with a **5-minute TTL as a safety net**, because the cache
is per process: in a scaled deployment the worker that handled the write is the only one that learns
of it, and without the TTL every other worker would serve the old favicon until the next restart.
The shell also carries the resolved theme as a `<style id="theme-boot">:root{…}</style>` block, last
in `<head>` so it follows the built stylesheet and wins the equal-specificity tie. It exists only to
stop a themed instance painting the shipped palette for one frame; `SiteContext` removes it once the
`/public/settings` payload has arrived and applied — gated on a **successful** fetch, since dropping
it after a failed one would strip a themed instance back to the shipped colors. Token names and
values are re-checked against conservative patterns on the way into the block: everything there comes
from a closed set already, and this keeps that a property of the HTML writer rather than of a
validator three modules away.
---
## 5. Site mode (LIVE / MAINTENANCE)
@@ -975,7 +1142,7 @@ it picks, it fails open on one side.)
| Nav | `GET /public/shard/features` returns only what the caller may reach, so the SPA never renders a link that would 403. Presentation only. |
Config reads are cached ~5s, so admin changes take effect within seconds **including on already-open
streams**. `PUBLIC_KINDS` still exists and is still exported (`notificationStreams.js`) but is now
streams**. `PUBLIC_KINDS` still exists and is still exported (`utils/shardBroadcast.js`) but is now
**derived** from the kind map rather than hand-maintained, so the two cannot drift.
**`PUBLIC_KINDS` is a module-load constant and must not be used to answer "may this caller read this

914
website/MODULE_API.md Normal file
View File

@@ -0,0 +1,914 @@
# The Module API — the contract
**Status:** Phase 1 deliverable of [MODULE_SYSTEM.md](MODULE_SYSTEM.md), **validated by the atlas
spike** — Part 7 records what the spike proved, what it changed in this contract, and the three
artifacts in it that are not design. This document is the
normative contract between the core website and an installed module. `MODULE_SYSTEM.md` decides
*what* the module system is; this decides *exactly what a module may call, what it must provide, and
what core promises not to break*.
Everything below is derived from what the UO code actually does today, re-read against the working
tree on 2026-08-10. Where the survey contradicted `MODULE_SYSTEM.md`, the contradiction is recorded
in Part 6 rather than quietly resolved — four of them, one of which (OpenAPI, §6.1) needs a decision
before Phase 2 starts.
**The one rule everything else serves:** a module reaches core *only* through the members named in
this document. Zero `require`/`import` from a module to a core file, enforced in CI (§5.1). A core
refactor that leaves this contract intact cannot break a module; anything a module needs that is not
here extends the contract first, in this file, before the module is written against it.
---
## Part 1 — Versioning
### 1.1 `MODULE_API_VERSION`
Core exports a single integer-major semver string from `server/src/modules/version.js`:
```js
const MODULE_API_VERSION = '1.0.0'
```
Every `module.json` declares a `coreApi` semver **range**. The loader checks it at boot, before it
requires a line of module code, and a mismatch fails that module loudly into `startup_failed`
(§4.4) with the two versions in the reason. It never silently proceeds.
| Change | Bump |
| --- | --- |
| A member is added to `ctx`, or a new `register*` call appears | minor |
| A member is removed or its signature changes | major |
| Behaviour of an existing member changes without a signature change | major |
| A core-internal refactor behind an unchanged member | none |
This is a **separate number from `PROTOCOL_VERSION`**, which versions the shard wire and has nothing
to say about a website module. It is also separate from the module's own version.
### 1.2 What is *not* contract
Core's internal file layout, table names, middleware ordering, the `api` client object's shape, and
every component under `client/src/components/` except the ones named in §3.4. A module that reaches
any of these is out of contract even if it happens to work.
---
## Part 2 — The server contract
### 2.1 `module.json`
Read synchronously by the loader from `modules/<id>/module.json`. Unknown top-level keys are
rejected rather than ignored, so a typo is a loud failure and not a silently-inert setting.
```json
{
"id": "uo",
"name": "Ultima Online",
"version": "1.0.0",
"coreApi": "^1.0.0",
"server": "server/index.js",
"client": { "entry": "client/dist/entry.js" },
"schema": "server/db/schema.sql",
"purge": "server/db/purge.sql",
"mounts": {
"public": ["/shard", "/atlas"],
"admin": ["/shard", "/uo-link"],
"player": ["/shard"]
},
"extensions": ["admin.users.detail"],
"capabilities": ["shard", "atlas", "market"]
}
```
| Key | Required | Meaning |
| --- | --- | --- |
| `id` | yes | `^[a-z][a-z0-9-]{1,31}$`. The directory name, the `installed_modules` key, the URL segment, the `window.__rg` registry key. Must equal the directory it was read from. |
| `name` | yes | Human label for the admin Modules screen. |
| `version` | yes | Semver. Recorded in `installed_modules`; shown on failure. |
| `coreApi` | yes | Semver range checked against `MODULE_API_VERSION` (§1.1). |
| `server` | no | Entry point, relative to the module root. Absent ⇒ client-only module. |
| `client.entry` | no | Prebuilt ESM chunk, relative to the module root. Absent ⇒ server-only module. |
| `schema` | no | Idempotent SQL fragment (§2.6). |
| `purge` | no | Destructive teardown (§2.6). Required if `schema` is present. |
| `mounts` | no | Declared prefixes per tier (§2.3). Declaration is the contract; the loader compares it against what the module actually registers and rejects a mismatch. |
| `extensions` | no | Core extension slots this module mounts into (§2.4). |
| `capabilities` | no | Opaque strings published by `GET /api/v1/public/modules`, for clients (the SPA, the Android app) to feature-detect against. |
### 2.2 The entry point
`server/index.js` exports a single function. It is called once, synchronously, during `app.js`
require — **not** after the database is up.
```js
module.exports = function register(ctx, api) { /* … */ }
```
It must not `await`, must not touch the database, and must not throw for a reason that a retry would
fix. Everything that needs a live database belongs in `onBoot` (§2.5). This constraint is not
stylistic: `scripts/routeManifest.js` and `swagger/swagger.js` both require `app.js` with the pool
pointed at a dead port, and a module that queried at registration time would hang both.
### 2.3 `ctx` — what core hands the module
Every member below exists because a UO file uses it today. Nothing is speculative, and nothing that
module-uo does not need is on the list.
| Member | Signature | Backed by | First real caller |
| --- | --- | --- | --- |
| `ctx.express` | the `express` namespace | core's `node_modules` | every module router (§7.2) |
| `ctx.validator` | the `express-validator` namespace | core's `node_modules` | `atlas.router.js` |
| `ctx.db.query` | `(sql, params?) => Promise<rows>` | `utils/db` | every `*.db.js` |
| `ctx.db.pool` | mariadb pool | `utils/db` | `shardAtlas.db.js` (streamed import) |
| `ctx.log` | `(namespace) => { error, warn, info, debug }`, each `(msg, meta?)` | `utils/logger` | all nine UO utils |
| `ctx.settings.get` | `(key) => Promise<string\|null>` | `model/settings` | `shardAtlas.model` |
| `ctx.settings.set` | `(key, value, updatedBy?) => Promise<void>` | `model/settings` | `shardAtlas.model:60` |
| `ctx.settings.getInstanceName` | `() => Promise<string>` | `model/settings` | `shardIngest.js:84` |
| `ctx.auth.getUserFromRequest` | `(req) => { id, username, role } \| null` | `utils/auth` | `shardVisibility.js` |
| `ctx.push.publish` | `(streamId, { ref?, ownerUserId? }) => Promise<void>` | `utils/pushDispatch:92` | `shardIngest.js:22` |
| `ctx.secretBox` | `{ encrypt(s), decrypt(s) }` | `utils/secretBox` | `uoLinkConfig.model` |
| `ctx.middleware` | `{ requireAuth, requireRole, siteMode, validate, noindex }` | `auth/session.middleware`, `middleware/*` | every UO router |
| `ctx.uploads` | `{ upload, UPLOAD_DIR, MIME_EXT }` | `admin/imageUpload.js` | atlas art import |
| `ctx.posts` | `{ listAll, getById, linkAnnounceJob, markAnnounced }` | `model/posts` | `newsGump.js:108`, `announceWorker.js:58` |
| `ctx.paths.moduleRoot` | absolute path to `modules/<id>/` | loader | atlas art, cliloc files |
| `ctx.moduleId` | the id from `module.json` | loader | log tags, table checks |
Three narrowings from `MODULE_SYSTEM.md` §2.1, all deliberate:
- **`ctx.auth` is one function, not `utils/auth`.** The facade also re-exports `signToken`,
`setAuthCookie` and the TOTP challenge primitives. Minting sessions is core's job; a module that
needs an identity needs to *read* one.
- **`ctx.settings` is three functions, not the model.** The model exports 24 names, most of them
registration/game-signup/app-links policy that is core's business.
- **`ctx.posts` is four functions.** `create`/`update`/`remove` are the CMS, not a module's.
And one addition the spike forced: **`ctx.express` and `ctx.validator`**. A module lives at
`<repo>/modules/<id>/`, outside `server/`, so Node's resolver never reaches `server/node_modules` and
`require('express')` from a module simply fails — which is how this was found. Even where it
resolved, a second express in the process is a second `Router` prototype. Core owns one express, as
it owns one React (§7.2).
`ctx` is frozen (`Object.freeze`, one level deep) before it is handed over. That is a guard against
accident, not against a hostile module — per `MODULE_SYSTEM.md` §2.2 the boundary is organisational,
not a security boundary.
### 2.4 `api` — what the module registers
The second argument. Every call is synchronous, idempotent-free (calling twice is an error), and
validated at once rather than at first use.
```js
api.registerRoutes({ public: {...}, admin: {...}, player: {...} })
api.registerExtension(slot, router)
api.registerNotificationStreams(streams)
api.registerAnnounceLeg({ leg, label, dispatch, classify })
api.onBoot(async (ctx) => {})
api.onShutdown(async () => {})
```
**Every call STAGES; nothing is committed until the module as a whole is known good.** A claim's
shape is checked at the call, so a malformed one throws with the registrant's own stack; whether the
name is *taken* can only be answered once the batch is complete, and is checked when the loader
commits it in its second pass. The consequence is the one that matters: a module that registers two
streams and then throws — or fails `checkDeclared` after `register()` returns — has left nothing
behind. A half-registered catalog would be worse than a missing one, because it is a subscribable
stream nothing will ever publish to. This is the registry-side twin of §4.3's second-pass mount rule,
and both exist for the same reason.
**`registerRoutes(mounts)`** — one `express.Router()` per prefix per tier:
```js
api.registerRoutes({
public: { '/shard': shardRouter, '/atlas': atlasRouter },
admin: { '/shard': adminShardRouter, '/uo-link': uoLinkRouter },
player: { '/shard': playerShardRouter },
})
```
The keys must match `module.json`'s `mounts` exactly. Prefixes are validated `^/[a-z0-9][a-z0-9-]*$`
— one segment, no nesting, no parameters — and rejected on collision with core's own mount table or
with another module's, at registration time. The router is mounted *inside* the tier, so it
structurally cannot reach above its prefix.
**The tier gate is already applied.** A router registered under `admin` sits behind
`noindex, isLoggedIn, requireRole('admin','editor','moderator')` from `router/v1/admin/index.js`;
under `player`, behind `noindex, requireAuth`; under `public`, behind nothing, by design. A module
adds per-route gates on top of that and never re-implements the tier gate.
**`registerExtension(slot, router)`** — the §1.9 case: module routes hanging off a *core* resource.
Only core may declare a slot; a module may only fill one. Exactly one slot exists in v1:
| Slot | Mounted at | Declared by |
| --- | --- | --- |
| `admin.users.detail` | `/api/v1/admin/users/:id` | `router/v1/admin/users.router.js` |
The router receives `req.params.id` from the parent (`mergeParams: true`). Two modules filling the
same slot is a collision and is rejected; core's own routes on the resource always win a path
conflict.
**`registerNotificationStreams(streams)`** — §1.8's push catalog.
An array of `{ id, label, description, personal, requiresLinkedAccount }` appended to core's catalog.
Ids are namespaced `<moduleId>.<name>` and rejected otherwise, save for the seven grandfathered ones
in §6.4.
Two amendments this signature carries, both settled 2026-08-10 with PR 4:
- **`mapEvent` is gone.** The earlier signature took `{ streams, mapEvent }`, with core's dispatcher
calling `mapEvent(event) => streamId`. That was a leftover from before §1.8's push inversion was
settled: the module owns `fromShardEvent` outright and calls `ctx.push.publish(streamId, …)` with
an id it has already resolved, so core never needs a second way to get there. What core wants from
a module here is the catalog — for the subscribe endpoint, for validating a subscription write, and
for the personal/linked-account gate. It follows that the public-safety filter (a sensitive event
kind can never produce a *public* push) is module-internal; that is the right home, because the
kinds, the streams and the filter are then one file that moves together, rather than a rule in core
about data only the module defines.
- **Two booleans, not one `scope`.** The entry shape above is the response body of
`GET /auth/me/notifications/streams`, which a shipped Android client already reads
(`NotificationsDto.kt`). `scope` was never the wire shape.
**`registerAnnounceLeg({ leg, label, dispatch, classify })`** — §1.8's news dispatcher.
`leg` is a namespaced id, `label` is what the admin panel shows, `dispatch(post) => Promise<result>`
delivers, and `classify(result) => { outcome, error }` maps the client's result to
`done` / `retry` / `terminal`. A leg that throws is caught, classified as a retry, and never blocks
another leg.
`label` is an addition: the panel used to hold a client-side `{ towncrier, discord }` label table,
which would have left a module's leg rendering as a bare id. It comes from the registration so a
module needs no client change.
**Legs are rows, not columns.** `announce_jobs` carried a `towncrier_*` and a `discord_*` column
group until PR 4; a module cannot `ALTER` a core table, so a registered leg had nowhere to live. The
per-leg state moved to `announce_job_legs (job_id, leg, status, attempts, last_error,
next_attempt_at)` and `leg` is a stored value. The parent `status` rollup is over *all* the job's
legs — done when every leg delivered, failed when every leg gave up, partial in between; and `done`
when a job has no legs at all, since nothing is left to deliver.
**`onBoot(fn)` / `onShutdown(fn)`** — §2.5.
### 2.5 Lifecycle
```
require(module) → register(ctx, api) → [routes mounted, app.js require returns]
↓ (server.js, after ensureSchema + seed)
onBoot(ctx) → started
↓ (SIGINT/SIGTERM)
onShutdown()
```
`onBoot` is where the eight `server.js` UO call sites go (`MODULE_SYSTEM.md` §1.7): the atlas and
cliloc refreshes, the market display-name backfill, `uoLinkSocket.start()`, the sidecar probe.
It runs **after** `ensureSchema()` (so the module's own tables exist) and after `seedDefaults()`,
and **before** the HTTP listener binds — a module that must not serve traffic before it has warmed
its cache gets that for free.
`onShutdown` runs before anything core owns is closed — the database pool, the push dispatcher and
the SSE fan-out are all still open, because a module's `onShutdown` is the only chance it gets to
flush through them. Reverse registration order, with a 5-second budget per module; exceeding it is
logged and the hook abandoned rather than hanging the process. Abandoned, not cancelled: nothing can
stop a promise that is still running, but the process is exiting anyway and the alternative is a host
where `systemctl stop` waits for SIGKILL.
**`onBoot` has no budget, deliberately.** Shutdown races the process being killed; boot does not. A
slow `onBoot` delays the listener binding, which is the guarantee two paragraphs up rather than a
problem to be timed out, and core's own boot steps are awaited exactly the same way.
Both hooks are optional, and both are individually try/caught. An `onBoot` that throws marks that
module `startup_failed` (§4.4) and the site still comes up — its routes stay mounted but its dispatch
guard rejects them with 503, because a module that failed to warm up serving half-initialised data is
worse than a module that says it is down. A module with no `onBoot` at all still reaches `started`:
having nothing to warm up is not the same as never having started, and the row has to agree with the
guard about whether the module is serving. A module whose `onBoot` threw gets **no** `onShutdown` — it
is part-way through a warm-up it never finished, and handing it a half-built world to tear down is
worse than not closing cleanly.
`onBoot` receives the same frozen `ctx` object `register()` was given, not a second one built to look
like it.
**What a boot does to `installed_modules`** (`MODULE_SYSTEM.md` §2.4). The dispatch is the second half
of a reconcile, and the order of its four steps is the design:
1. Clear the last boot's outcomes, so what is on display afterwards is what *this* boot did.
`disabled` rows are left alone — that is an operator decision, not an outcome.
2. Write a row for every module found on the volume, with null provenance if it has none. A directory
placed on the volume by hand is a supported install (§2.5 of the design of record) and without a
row it could be neither disabled nor reported.
3. Mark any row whose directory is **not** on the volume `startup_failed` (stage `require`). Step 1
has just reset it to `enabled`, and a row claiming to be enabled for a module that is not there is
the one state that is simply untrue. A plain uninstall leaves `disabled`, which step 1 never
touches, so this catches only a directory deleted by hand.
4. Write down the outcome each module already carries — disabled by the operator, or failed during
load or schema replay, both of which happen before the database is reachable — and only then
dispatch `onBoot`.
**The operator's switch wins over everything, including a failure.** A module whose row says
`disabled` is guarded (§4.5), is not booted, and does **not** have its failure re-recorded:
overwriting a deliberate `disabled` with an outcome would silently switch it back on at the next
boot.
**A bookkeeping failure is not a boot failure.** Every database write in the reconcile is individually
caught. A row that will not update is bad — the admin panel shows the wrong thing — but it is
strictly less bad than a site that will not start, and it must not stop the modules behind it from
booting.
Dispatch and reconcile live in `server/src/modules/lifecycle.js`, not in the loader: `routeManifest.js`
and `swagger.js` both require `app.js` against a dead pool (§4.1), so the loader may not reach the
database. The two halves meet at exactly one place — `loader.setState()` — so the in-memory record
the dispatch guard reads and the row the admin panel reads are moved together and cannot disagree.
### 2.6 Schema fragments
`schema` is an idempotent `.sql` file replayed by the same `ensureSchema()` that replays core's,
immediately after it, statement by statement, split the same way. It is subject to the same rules
core's file already follows: `CREATE TABLE IF NOT EXISTS`, `ALTER TABLE … ADD COLUMN IF NOT EXISTS`,
no `--` inside a string literal, no `DROP`.
"Split the same way" is shared code, not a shared description: `utils/sqlStatements.js` holds the
splitter and both callers use it. It is its own file rather than an export of `utils/db.js` because
the loader validates fragments at require time and must not pull the mariadb pool into `app.js`'s
require chain to do it.
**The rules above are enforced at LOAD time, not at replay time** (PR 3). Everything §2.6 states
about the SQL is knowable by reading the file, so a fragment that breaks a rule costs the module its
mount entirely (§4.4's left-hand column) rather than mounting and then 503ing with tables half
created. What is left for the replay is the class of failure only the database can report — an
unknown column type, a bad foreign key — and those are post-mount and answer 503.
**The check is a leading-verb allowlist: `CREATE`, `ALTER`, `INSERT`, `UPDATE`.** Those are the four
core's own `schema.sql` uses. It is an allowlist rather than the `DROP` denylist this section words
it as because a fragment is **replayed on every boot**: `TRUNCATE` and `DELETE` would empty a table
at every restart, `RENAME` would fail at the second one, and `GRANT`/`SET`/`USE` are core's business.
A denylist only ever bans what somebody thought of. It is a leading-verb check and claims no more:
`ALTER TABLE x DROP COLUMN y` passes it, and catching that needs a SQL parser — a large dependency
for a rule whose job is stopping the obvious foot-gun early. A `CREATE TABLE` missing `IF NOT EXISTS`
is rejected on the same grounds: it succeeds exactly once and fails every boot after, presenting to
an operator as a module that broke on restart.
**The replay is outside `ensureSchema()`'s retry loop.** Core's schema is retried ten times while the
database comes up; a fragment that throws is one module's failure, not a signal the database is not
ready, and retrying core's whole schema over one module's bad SQL would turn a 503'd module into a
two-minute boot. Partial application is accepted rather than compensated for — MariaDB self-commits
each DDL statement, so no transaction could roll back the tables created before the failing one, and
the idempotence rule is what makes re-running a corrected fragment safe.
**One caller replays nothing, deliberately.** `db/seed.js` (`npm run seed`) calls `ensureSchema()`
standalone without requiring `app.js`, so no scan has happened and `fragments()`'s §7.6 throw would
break seeding outright. The replay asks `isLoaded()` and logs the skip. That is the only sanctioned
use of that predicate: everywhere else, reading the module list before `load()` still throws, because
a booting server quietly getting no module tables is precisely what §7.6 exists to prevent.
**Table names are namespaced and collision-checked.** New tables must be prefixed `<id>_`. The
loader extracts every `CREATE TABLE IF NOT EXISTS <name>` from the fragment and rejects the module
if a name collides with a core table or with another module's — a wrong `DROP`-free fragment can
still silently adopt someone else's table otherwise.
**module-uo is grandfathered.** Its 27 tables are named `shard_*` (26) and `uo_link_config` (1), and
renaming them is a data migration this workstream explicitly does not do (`MODULE_SYSTEM.md` §1.6
puts the count at 25; the working tree says 27 — see §6.4). They are registered in the loader as an
explicit legacy allowlist keyed to `id: "uo"`, so the prefix rule holds for every module written
after this one.
`purge` is the destructive counterpart, run **only** by the explicit admin purge action, never by
uninstall. Required whenever `schema` is present: a module that can create tables and cannot drop
them leaves an operator with orphaned data and no supported way to remove it.
### 2.7 What a module must not do
- `require` anything outside its own directory except node built-ins and its own `dependencies`.
- Mutate `ctx`, `req.user`, or any object core handed it.
- Register an Express error handler, or any middleware at the app level.
- Read `process.env` for core configuration. Its own config is a `settings` key or its own table.
- Call `process.exit`, install signal handlers, or start a listener.
- Write outside `ctx.paths.moduleRoot` and the upload directory.
### 2.8 The OpenAPI fragment
Every module that registers routes ships `swagger-fragment.json` in its bundle root. Core merges the
fragments of started modules into `/api/docs.json`; the full reasoning and the collision rules are
§6.1a. In short: fully-qualified paths, namespaced schema keys, module CI fails if a registered
route has no path in the fragment, and core wins every key collision.
---
## Part 3 — The client contract
### 3.1 How the chunk gets there
Exactly as `MODULE_SYSTEM.md` §2.6 resolved, and Phase 1's spike is what proves it:
1. Module CI builds `client/dist/entry.js` with Vite in **library mode**, `react`, `react-dom`,
`react-dom/client` and `react-router-dom` declared **external**.
2. Core serves the module directory statically at `/modules/<id>/` — same-origin, so
`script-src 'self'` (`config/csp.js:49`) admits it with no nonce and no inline.
3. `utils/htmlShell.js` injects `<script type="module" src="/modules/<id>/entry.js">` at the
`</head>` rewrite it already performs (line 111), for each **started** module.
4. Before that tag, core has published `window.__rg` (§3.2) from its own bundle. The module's
externals resolve against it.
There is exactly one React instance and core owns it. A module that bundles its own React will
produce two copies of the hook dispatcher and fail at the first `useState`; the externals config in
§3.5 is what prevents it.
### 3.2 `window.__rg`
Populated by core's `main.jsx` **before** it renders, and frozen afterwards.
```js
window.__rg = {
version: '1.0.0', // MODULE_API_VERSION — the same number as the server's
react, // the React namespace
reactDom, // react-dom/client
router, // react-router-dom namespace
jsxRuntime, // react/jsx-runtime — see below
registry, // §3.3
ui, // §3.4
api, // §3.5
}
```
`jsxRuntime` is not decoration. A module's bundler compiles every `.jsx` file to imports from
`react/jsx-runtime` under the modern automatic runtime, and those have to resolve to *core's* React
like every other import. Without it on the global a module would have to build with
`jsxRuntime: 'classic'`; with it, the module uses the default its tooling already assumes.
A module entry checks `window.__rg.version` against its own `coreApi` range and refuses to register
on a mismatch, logging once — the client-side twin of §1.1, and the reason `version` is here at all.
### 3.3 `registry` — what the module registers
```js
registry.registerRoutes(id, { public: [...], admin: [...], player: [...] })
registry.registerNav(id, { area, items })
registry.registerFeatureProvider(id, namespace, hook)
```
**`registerRoutes`** — arrays of `{ path, element, gate? }`. Paths are relative to the module's
namespace and core prefixes them (`MODULE_SYSTEM.md` §2.8):
| Area | Rendered at | Wrapped in |
| --- | --- | --- |
| `public` | `/<id>/<path>` | `MaintenanceGate` |
| `admin` | `/admin/<id>/<path>` | `RequireAuth` + `AdminLayout` |
| `player` | `/player/<id>/<path>` | `RequirePlayer` + `PlayerPortalLayout` |
`gate` is an optional `{ roles: [...] }`, applied by core as the existing `RoleGate`. A module cannot
supply its own auth wrapper — that is the one place where the client boundary is load-bearing, since
the sidebar and the route table must agree about who may see what.
`App.jsx` stops being a flat static table and becomes core's routes plus
`registry.routesFor(area)`. Registration happens at entry-script evaluation, which is before
`createRoot().render()`, so nothing renders against a half-populated registry.
**`registerNav`** — items interleave into *core* groups (`MODULE_SYSTEM.md` §1.4):
```js
registry.registerNav('uo', {
area: 'admin',
items: [
{ label: 'Shard ops', to: '/admin/uo/shard-ops', group: 'Moderation', order: 30,
roles: ['admin', 'moderator'] },
{ label: 'Shard', to: '/admin/uo/link', group: 'System', order: 10 },
],
})
```
`group` names an existing core group; an unknown group name appends a new group at the end rather
than dropping the item. `order` sorts within the group, core items keeping their current positions.
`feature` (public area only) names a flag resolved by §3.3's provider.
`MOD_PATHS` in `AdminLayout.jsx:109` — today a hardcoded allowlist of two UO paths — becomes a
computation over each item's `roles`, so moderator visibility follows from the registration instead
of from a second list that has to be kept in sync.
The pipeline is unchanged from `THEMING_AND_NAV.md`, with one new first step:
> registered defaults (core **+ modules**) → role/feature filtering → admin overrides → rendered nav
**`registerFeatureProvider`** — core keeps a generic flag context; the module supplies the hook that
fills its namespace (`useShardFeatures` for `uo`). With no module installed the filter is a correct
no-op, because no core nav item carries a `feature` today.
### 3.4 `ui` — the shared component kit
**This is the largest addition Phase 1 makes to the plan, and it is not optional** (§6.2; approved
2026-08-10). The atlas
pages alone import five core modules that are not React and not the router: `PublicLayout`,
`PageHeader`, `Loading` / `ErrorState` / `EmptyState`, and `useAsync`. Without a shared kit a module
either reaches into core's tree (violating the zero-import rule) or ships its own copies, which
means a module page that does not look like the site it is installed in — and drifts further every
time core's layout changes.
The kit is **curated and closed**, not a re-export of `components/`:
| Export | From | Why it is in the kit |
| --- | --- | --- |
| `PublicLayout` | `components/PublicLayout.jsx` | the public chrome; a module page without it is a bare page |
| `AdminPage` | `routes/admin/…` | the admin content frame |
| `PageHeader` | `components/PageHeader.jsx` | title/subtitle furniture |
| `Loading`, `ErrorState`, `EmptyState` | `components/PageState.jsx` | the three states every data page has |
| `useAsync` | `lib/useAsync.js` | the fetch/loading/error hook every data page uses |
| `useAuth`, `useSite` | `contexts/*` | read-only access to session and site settings |
Everything else — tables, chips, tabs, the tiptap editor, dnd-kit — a module bundles itself.
Adding to the kit is a **minor** `MODULE_API_VERSION` bump; changing a kit component's props is a
**major** one. That is a real constraint on core and it is the price of the boundary being worth
anything.
### 3.5 `api` — the request primitive
`client/src/api/client.js` is one 518-line object, and it already carries module namespaces:
`api.atlas` (line 196) and `api.shard` are UO bindings living in core's client. They move out with
the module.
Core exposes the primitive, not the object:
```js
window.__rg.api = { request, ApiError, BASE } // request(path, { method, body, headers, raw })
```
`request` is `client.js`'s existing `req` — same-origin `/api/v1`, `credentials: 'include'`, JSON
in/out, throwing `ApiError(status, message, body)`. A module builds its own namespace over it and
owns the paths it calls, which is correct: it owns the routes at the other end.
### 3.6 Vite library-mode build
The module's `vite.config.js`, and the four externals are the whole contract:
```js
export default defineConfig({
plugins: [react()],
build: {
lib: { entry: 'src/entry.jsx', formats: ['es'], fileName: () => 'entry.js' },
outDir: 'dist',
modulePreload: { polyfill: false }, // same reason as core: no inline bootstrap under CSP
rollupOptions: {
external: ['react', 'react-dom', 'react-dom/client', 'react-router-dom'],
output: { paths: { /* rewritten to window.__rg by the shim below */ } },
},
},
})
```
Rollup's `external` alone emits bare `import 'react'` specifiers, which the browser cannot resolve
without an import map — and CSP forbids the inline `<script type="importmap">` that would provide
one (`MODULE_SYSTEM.md` §1.14). The module therefore ships a two-line shim module that re-exports
from the global, and aliases the four externals to it:
```js
// src/shim/react.js
export default window.__rg.react
export const { useState, useEffect, useMemo, useCallback, useRef, createElement, Fragment } = window.__rg.react
```
**This is the highest-risk mechanical detail in the whole plan and it is exactly what the Phase 1
spike exists to prove.** If it does not hold, §2.6 of the design of record is wrong and the client
half needs rethinking before Phase 2 builds on it.
---
## Part 4 — The loader's obligations
### 4.1 Synchronous, filesystem-sourced
`app.js` scans `modules/*/module.json` with `fs.readdirSync` at require time and mounts what it
finds (`MODULE_SYSTEM.md` §1.12). The database is not consulted. `MODULES_DIR` defaults to
`<repo>/modules` and is overridable by env for tests and for the Docker volume mount.
**The trigger is one explicit call, and there is no lazy self-scan** (§7.6). `app.js` calls
```js
modules.load({ public: publicRouter, admin: adminRouter, player: playerRouter })
```
exactly once, and every accessor throws until it has run rather than answering with an empty list —
"no modules installed" is a real state, and a caller must not be handed it by accident. Its position
in `app.js` is load-bearing in both directions: **after** `app.use('/api', apiRouter)`, so every core
prefix is already on the tier routers when §4.3 asks them what core owns and so first-match-wins
means a module cannot shadow a core route; **before** the `/api` 404, so a module route reaches its
handler instead of the catch-all.
**Mounting is a second pass** over the modules that survived validation, not part of the scan loop.
Otherwise the first module's layers sit on the tier router while the second is being validated,
indistinguishable from core's — the second module would be told it collided with *core*, naming the
wrong culprit, and the module-versus-module check would be unreachable.
### 4.2 Order
Alphabetical by `id`, deterministically. There is no dependency resolution between modules (§2.0 of
the design of record puts it out of scope) and alphabetical order is the honest way to say so — any
other order would imply a precedence that is not being computed.
### 4.3 Validation, in this order
1. `module.json` parses; no unknown keys; `id` matches the directory.
2. `coreApi` satisfied by `MODULE_API_VERSION`.
3. Declared `mounts` prefixes are well-formed and collide with nothing.
4. Declared `extensions` slots all exist.
5. `schema`/`purge` files exist and are readable; table names are namespaced or allowlisted.
6. `require(server)` succeeds and exports a function.
7. `register(ctx, api)` returns without throwing, and registers exactly what `module.json` declared.
A failure at any step is that module's failure and nobody else's.
**Step 3 asks the live tier routers, not a list.** Whether core owns a prefix is answered by probing
the tier router's own stack with express's `layer.match()`, skipping root-mounted (`fast_slash`)
layers — `public/index.js` ends with `use('/', siteRouter)` and `admin/index.js` with the dashboard
router, and both match every path, so counting them would report every prefix as taken and no module
could ever mount. A hardcoded prefix table was tried in the spike and was already one prefix stale
when it was written; deriving it means the check cannot drift the first time core adds a capability
router, and needs no second declaration of the mount table.
### 4.4 `startup_failed` is a state, not a crash
Per `MODULE_SYSTEM.md` §2.4, the loader try/catches the **entire** lifecycle — require, validation,
registration, schema replay, `onBoot` — and any failure marks the module `startup_failed` with the
reason recorded in `installed_modules`, visible in the admin panel, recoverable without shell
access. The site comes up.
Two sub-cases differ, and the difference matters:
| Failure before routes are mounted | Failure after (schema, `onBoot`) |
| --- | --- |
| routes and nav are simply absent | routes stay mounted; the dispatch guard returns **503** |
The second is what keeps the URL surface deterministic and generatable: `routes.manifest.json` must
not depend on whether a module's boot hook happened to succeed on the machine that generated it.
**Every failure is recorded against the step that produced it**, in `failure_stage`, so the admin
panel can say *where* a module broke and not only what the message was. The stages are §4.3's seven
validation steps plus `boot`:
| Stage | The step that failed |
| --- | --- |
| `manifest` | `module.json` unparseable, an unknown key, a bad or mismatched `id`, no `version` |
| `core_api` | `coreApi` missing, or not satisfied by `MODULE_API_VERSION` |
| `mounts` | a malformed prefix, or one already owned by core or another module |
| `extensions` | a declared slot that does not exist |
| `schema` | a fragment breaking a §2.6 rule at load, or a statement the database rejected at replay |
| `require` | the entry point threw, or did not export a function — also a row whose directory is gone |
| `register` | `register()` threw, a claim was malformed, or what it registered ≠ what it declared |
| `boot` | `onBoot` threw |
The four steps that share one function label themselves; the rest are inferred from how far the load
had got, and an unlabelled throw is recorded against the step that was running rather than guessed
at. Every non-failing transition clears both the stage and the reason, so a running module can never
show the failure it had two boots ago.
### 4.5 The disabled guard
A module disabled in `installed_modules` is *mounted and guarded*, never unmounted — a one-line
`if (!enabled) return res.status(404)` ahead of its tier mount. Same reason: the URL surface is a
property of the filesystem, not of a database row.
---
## Part 5 — Enforcement
### 5.1 Zero internal imports (CI, module repo)
The acceptance test for the whole contract. In the module's own CI:
```
grep -rE "require\(['\"]\.\./\.\./|from ['\"]\.\./\.\./\.\./" server/ client/src/
```
— refined to "no relative path that escapes the module root", plus a check that the only bare
specifiers in the client bundle are the four declared externals. A hit fails the build.
### 5.2 Zero UO identifiers in core (CI, website repo)
Phase 3's acceptance criterion 1: no `shard`, `uoLink`, `cliloc`, `atlas` or `towncrier` outside
`modules/`, as a CI grep test rather than a review promise.
### 5.3 Zero-line route manifest diff (CI, both repos)
`npm run routes:manifest -- --check` in core; the module generates and freezes its own manifest in
its own repo, using the same script pointed at a core+module app. Phase 2 must produce a zero-line
diff in core's; Phase 3 moves the UO entries out of core's and into module-uo's, which is the one
diff the whole workstream is allowed.
---
## Part 6 — Amendments to MODULE_SYSTEM.md
Four things the survey found that the design of record gets wrong or does not cover. The first
needs a decision.
### 6.1 OpenAPI generation does not survive a dynamic loader — **settled: fragment merge**
`MODULE_SYSTEM.md` §1.12 treats `scripts/routeManifest.js` and `swagger/swagger.js` as the same
problem, because both require `app.js` with no database. They are not the same problem.
- **`routeManifest.js` walks the live Express stack** (`app._router.stack`, line 175). It is runtime
introspection and a filesystem-scanning loader is invisible to it in the best way: whatever got
mounted, it sees.
- **`swagger/swagger.js` is static analysis.** `swagger-autogen` is handed `routes = ['./src/app.js']`
(line 29) and *parses the source text*, following `app.use(...)` to the required file. A
`require(path.join(dir, manifest.server))` inside a `for` loop is not statically resolvable. Module
routes will be **absent from `swagger-output.json`** — silently, with no error.
That collides directly with CLAUDE.md's standing rule: *never ship a route that isn't in the OpenAPI
spec*. Three ways out:
| Option | How | Cost |
| --- | --- | --- |
| **A. Fragment merge** ✅ | Module CI runs swagger-autogen against its own `server/index.js` and ships `swagger-fragment.json` in the bundle. Core deep-merges the fragments of started modules into `/api/docs.json` at request time. | one merge helper in core (~40 lines); the module owns its own spec, which matches "one repo, one bundle" |
| B. Glob the modules dir | Core's `swagger.js` adds `modules/*/server/index.js` to `routes` when present. | core's *committed* spec then depends on which modules the developer had checked out — a spec that differs per machine |
| C. Hand-write module paths into core's spec | — | a second source of truth; drifts on the first module release |
**Decided 2026-08-10: A.** It is the only one that keeps the spec correct on an operator's box,
where core is a prebuilt image and the module arrived afterwards. The obligation this puts on a
module is §2.8; the one it puts on core is Phase 2 item 2.
### 6.1a The obligation, on each side
**Module:** a `swagger-fragment.json` in the bundle root, generated by its own CI with the same
`swagger-autogen` tooling pointed at its own entry point, carrying only `paths`, `tags` and
`components.schemas`. Its paths must be fully qualified (`/api/v1/public/atlas/creatures`), because
the module knows its own mount prefixes and core does not re-derive them. Its `components.schemas`
keys are namespaced (`UoAtlasCreature`, not `AtlasCreature`) so two modules cannot collide in the
merged spec. CI fails the module build if a route it registers has no path in its fragment — the
per-module form of "never ship a route that isn't in the spec".
**Core:** `/api/docs.json` merges the fragments of **started** modules over its own committed spec at
request time (cached, invalidated on a module state change). Merge is shallow-per-section and
**core always wins a key collision** — a module cannot redefine a core path, tag or schema by
shipping one with the same name; the collision is logged and the module's version dropped.
`swagger-output.json` itself stays exactly what core's own routes generate, so `npm run swagger`
remains reproducible on any machine regardless of what is installed.
### 6.2 The client contract is much larger than §2.1 says
§2.1 lists three client registration calls and nothing else, implying React and the router are all a
module needs. The atlas pages disprove it: `Atlas.jsx` imports `PublicLayout`, `PageHeader`,
`PageState`'s three states, `useAsync` and `api` — five core modules beyond React, on the *smallest*
UO page. Hence §3.4's curated UI kit and §3.5's request primitive. This is an addition to the
contract, not a change of direction, but it makes core's component API a versioned surface, which it
has never been before.
### 6.3 `shardVisibility` is module-owned, and core's nav depends on it
`utils/shardVisibility.js` is a UO util that provides `requireFeature` and `project` — and the atlas
routes, the spike target, are gated by it (`atlas.router.js:25`). So the spike carries
`shardVisibility` plus the `shardVisibility` and `shardLinks` models with it, not just the atlas
files. Two consequences:
- On the **server** this is fine: `requireFeature` becomes module-internal middleware, and only
`PUBLIC_KINDS` crosses the boundary — already handled by §1.8's `registerNotificationStreams`
inversion.
- During the **spike** there will be two copies of `shardVisibility` in one process (the module's,
and core's for the not-yet-extracted shard routes) with two independent 5-second caches over the
same table. Functionally identical, and a spike-only artifact that Phase 3 resolves by moving the
original. Recorded so it is not mistaken for a design flaw.
### 6.4 Two counts in §1.6 and §2.7 are off
- **27 UO tables, not 25**: 26 `shard_*` plus `uo_link_config`. §1.6 says "the 24 `shard_*` tables
plus `uo_link_config`".
- **The atlas spike is 6 routes, not 5**: `/creatures`, `/creatures/:slug`, `/regions`,
`/landmarks`, `/champions`, `/meta`.
Neither changes a decision; both are corrected here rather than left to be tripped over when the
extraction is counted against the plan.
### 6.5 Grandfathered names, and why the prefix rules survive them
§2.4 requires a module's stream ids and announce legs to carry its module id. Eight names predate the
module system and cannot take it:
| Kind | Names | Why they cannot be renamed |
| --- | --- | --- |
| Streams | `server.status`, `idoc.warning`, `champ.start`, `governor.election`, `vendor.sale`, `house.idoc`, `account.login` | stored in `notification_subs` rows; read by a shipped Android client |
| Announce leg | `towncrier` | a stored value in `announce_job_legs.leg` and the body of the retry endpoint |
They are allowed to **`uo` alone**, by an explicit per-module allowlist — the same shape and the same
reasoning as the loader's `LEGACY_TABLE_PREFIXES` for module-uo's 27 tables. Grandfathering by
allowlist rather than dropping the rule is what keeps the rule real for every module written after
this one; the alternative leaves the first name collision to be discovered by a module silently
adopting someone else's stream.
### 6.6 An extension slot is invisible to static analysis — core needs the merge too
§6.1 settled the fragment merge for *modules*. PR 4 found that core needs the identical machinery for
its **own** slot fills, one phase earlier than the plan expected.
A slot's router is created by `registries.declareSlot()` and filled later, so there is no literal
`use(require(...))` for swagger-autogen to follow. Moving the six `/admin/users/:id/shard/*` routes
behind `admin.users.detail` therefore deleted 407 lines from `swagger-output.json` — with
`Swagger-autogen: Success` and no warning. Same failure as §7.4, different cause, and it would have
shipped six undocumented core routes against CLAUDE.md's standing rule.
`npm run swagger` now has a second step (`swagger/slotSpecs.js`): for each **filled** slot, generate a
fragment by pointing swagger-autogen at that router's own file, re-root its paths at the prefix the
router actually hangs at, and merge. Two things are derived rather than written down, because a
written-down copy drifts:
- **which** slots — from `registries.filledSlots()`;
- **where** each hangs — by finding the slot's own router object in the live express stack, decoding
the mount prefixes above it with `scripts/routeManifest.js`'s own `mountPath`, so the manifest and
the spec can never disagree about what a mount decodes to.
An empty fragment is a hard build failure, because an empty fragment is exactly what the silent drop
looks like. The merge itself is `swagger/mergeSpec.js` — the ~40-line helper §6.1a already owed core
for module fragments, written here and proved against core's own slot before a module depends on it.
This is **build**-time and lands in the committed spec, because slot routes are core's; a module's
fragment is still merged at **request** time into `/api/docs.json` (§6.1a), and `swagger-output.json`
stays reproducible on any machine regardless of what is installed.
`registerExtension` therefore takes a third, **core-only** argument: the file its router is generated
from. A module needs no equivalent — it ships a prebuilt `swagger-fragment.json`, because core never
has its sources to analyse.
---
## Part 7 — What the spike proved
The Phase 1 spike ran on `website` branch `spike/module-atlas`, cut from `edge` and **deliberately
never merged** — it is the evidence, not the implementation. Phase 2 rebuilds the loader properly.
### 7.1 The exit criteria
| Criterion | Result |
| --- | --- |
| No internal-file imports from the module into core | **pass** — the module's only non-builtin requires are `ctx.express` / `ctx.validator`; the built client chunk contains **zero** bare import specifiers |
| `npm run routes:manifest` produces a zero-line diff | **pass** — `routes.manifest.json` *and* `routes.guards.json` are byte-identical with the six atlas routes now served by the module |
| The chunk loads under the enforced CSP | **pass** — `/uo/atlas` and `/uo/atlas/:slug` render from `/modules/uo/entry.js` under `script-src 'self'`, with **zero** violation reports at `/api/csp-report` and a clean console |
| Everything still passes | **pass** — 729 core tests, 81 module tests |
Also verified end to end against the real database: the schema fragment replayed after core's
(`schema ensured for module "uo"`), `onBoot` ran the atlas refresh, the module reached `started`, and
the six API URLs answered 200 unchanged at `/api/v1/public/atlas/*`.
### 7.2 One express, one React — the same rule, twice
The single biggest thing the spike changed. `MODULE_SYSTEM.md` §2.6 got the client half right — one
React, shared via a global — and said nothing about the server, where the identical problem exists
and bites harder:
- A module lives at `<repo>/modules/<id>/`. Node's resolver walks *up* from there and never reaches
`server/node_modules`, so `require('express')` inside a module **fails outright**. This was the
first error the spike hit.
- Installing express into the module would fix resolution and break something worse: two `Router`
prototypes, two sets of `instanceof` checks — and it would mean the operator running `npm install`
in a module directory, which is the build the whole plan exists to avoid.
Hence `ctx.express` and `ctx.validator`. The rule generalises: **anything shared between core and a
module is owned by core and handed over — never resolved by the module.** On the client that is
`react`, `react-dom/client`, `react-router-dom` and `react/jsx-runtime`; on the server it is
`express` and `express-validator`.
Two mechanical traps inside that, both cheap once known and both silent otherwise:
- **Vite's object-form `resolve.alias` does PREFIX matching.** A `react` key also rewrites
`react/jsx-runtime` into `src/shim/react.js/jsx-runtime`, a path that cannot exist. Use the array
form with anchored regexes (`/^react$/`).
- **`external` alone is not enough for an ESM library build.** Rollup then emits bare
`import 'react'`, which the browser cannot resolve without an import map — and CSP forbids the
inline `<script type="importmap">` that would supply one. Each shared dependency needs a two-line
alias shim that re-exports from `window.__rg`. `output.globals` does not help: it applies to
iife/umd output only.
### 7.3 §6.1 confirmed empirically, not just predicted
Regenerating the OpenAPI spec after the move deleted **361 lines** — all six atlas paths — from
`swagger-output.json`, with `Swagger-autogen: Success` and no warning of any kind. The route manifest
kept all six in the same run. That is the static-analysis-versus-runtime split of §6.1 happening for
real, and it is exactly the silent failure the fragment merge exists to prevent. Core's committed
spec is correct as regenerated — it describes core's own routes — and the six paths come back via
module-uo's fragment when Phase 2 item 2 lands.
### 7.4 The loader's failure guarantees are tested, not asserted
`server/test/moduleLoader.test.js` — 17 tests over the paths nobody exercises by hand: an entry point
that throws, a `coreApi` mismatch, an unknown manifest key, an id that disagrees with its directory,
two modules claiming one prefix, a module claiming a core prefix, registering an undeclared prefix
and declaring an unregistered one, a fragment naming a core table, an unprefixed table, a schema with
no purge, an `onBoot` that throws, an `onShutdown` that hangs and one that throws, a double
registration, and a probe asserting `ctx` exposes exactly the documented surface and is frozen.
The property under test throughout is the same: **the failing module fails alone.**
### 7.5 Spike artifacts that are NOT design
Three things in the branch are consequences of stopping at six routes, and Phase 3 removes all three.
They are recorded so nobody reads them as intended shape:
1. **Core reaches into the module twice** — `admin/shardAtlas.controller.js` and
`test/atlasController.test.js` require the module's model directly. The five admin atlas routes
live at `/admin/shard/atlas/*`, inside the `/shard` prefix core still owns, so the module cannot
take them without colliding or moving a URL. Phase 3 moves the whole `/shard` admin prefix at once
and the imports go with it.
2. **Two copies of `shardVisibility`** — the module's (as `utils/visibility.js`) and core's, for the
shard routes not yet extracted. Two five-second caches over the same table; functionally
identical. Predicted in §6.3, observed exactly as described.
3. **The module has no `swagger-fragment.json`** — §6.1a's obligation needs core's merge helper on
the other side of it, which is Phase 2.
### 7.6 A finding for Phase 2's loader — **settled: an explicit `load()`**
`scan()` was lazy — requiring the loader did not run it. That was deliberate (app.js decides when
modules are discovered) but it is a sharp edge: a caller that requires the loader and reads nothing
gets an empty, *silent* module list. It cost one confusing test failure during the spike.
**Phase 2 PR 2 made the trigger explicit** rather than scanning at require time. `app.js` calls
`modules.load(tierRouters)` once, and `list()` throws until it has. Scanning on require was the
alternative and was rejected for two reasons: the loader now needs the tier routers *handed to it*
for the §4.3 collision check, which a require-time side effect cannot receive; and it would make the
ordering constraint invisible, enforced by where a `require` sits rather than by an argument that is
missing if it is wrong.

729
website/MODULE_SYSTEM.md Normal file
View File

@@ -0,0 +1,729 @@
# The Module System — design of record
**Status:** approved design, not yet implemented. Every decision in Part 3 has been settled with the
org lead; Part 1 records what was verified against the working trees on 2026-08-10, including the
places the original draft was wrong.
**The normative contract is [`MODULE_API.md`](MODULE_API.md)** (Phase 1). This document decides what
the module system *is*; that one decides exactly what a module may call. Where the two differ, that
one wins — its Part 6 lists the four places it amends this document.
**Goal.** Turn Runic Gateway from a UO/ServUO-specific platform into a game-agnostic one. The core
architecture is unchanged — sidecar → website → browser. What changes is that game-specific
behaviour (routes, tables, screens, nav) leaves the core website and becomes an installable
**module**. An operator installs the base site, installs the module for their game, and restarts.
**The model is WordPress plugins, not a build system.** An operator never compiles anything to
deploy a module. The module's own CI publishes it prebuilt; the operator drops it in and enables it.
This single constraint drives most of Part 2.
**Out of scope.** The sidecar's per-game protocol adapters. `link/`, `servuo-plugins/` and
`installer/` are the shard side and stay independent of this work — the installer runs on the shard
host and by design never contacts the website (`installer/src/cli.rs:162`). Also out of scope: the
Android app, which gets its own plan covering module discovery and multi-server profiles; this plan
only owes it the capability endpoint in §2.5.
---
## Part 1 — What is actually there
### 1.1 The parts that are already clean
The extraction is closer to a folder move than a teardown, and that is not an assumption:
- **Models.** `server/src/model/` holds 31 directories; exactly 8 are UO — `shardAtlas/`,
`shardClilocs/`, `shardEvents/`, `shardLinks/`, `shardMarket/`, `shardState/`, `shardVisibility/`,
`uoLinkConfig/`. No mixing with `users/`, `posts/`, `pages/`, `wiki/`, `settings/`.
- **Routers.** All 13 UO router/controller files are single-purpose, with no shared code:
`admin/shard.router.js`, `admin/shardAtlas|shardClilocs|shardOps|shardVisibility.controller.js`,
`admin/uoLink.router.js` + `.controller.js`, `public/atlas.router.js` + `.controller.js`,
`public/shard.router.js` + `.controller.js`, `player/shard.router.js` + `.controller.js`.
- **Mount points.** `router/v1/{public,admin,player}/index.js` are pure mount tables that declare no
routes of their own. Module mounting drops straight in with no restructuring.
- **The API surface is small and knowable.** The nine UO `utils/` files import only four things from
core: `settings.model`, `logger`, `auth`, `pushDispatch`. That is the empirical basis for §2.1 —
the contract is derived from what the real code uses, not designed speculatively.
### 1.2 Route prefixes: one flat module prefix is impossible
A module cannot be handed a single pre-scoped router at, say, `/api/v1/game/uo`, because the existing
UO URLs live under three different access tiers — `/api/v1/public/shard/*`,
`/api/v1/admin/shard/*`, `/api/v1/player/shard/*` — and those URLs are protected by
`routeManifest.test.js` and consumed by three shipped clients (SPA, Android app, Discord bot).
**Resolved:** a module owns a *named slot inside each tier*. It still only ever holds a pre-scoped
`express.Router()` and structurally cannot reach above its mount point; it simply holds one per tier.
```json
"mounts": {
"public": ["/shard", "/atlas"],
"admin": ["/shard", "/uo-link"],
"player": ["/shard"]
}
```
The loader rejects a prefix collision between two modules, or between a module and core, at
registration time. That check is unavoidable; the per-request routing boundary is not left to the
module's good behaviour.
### 1.3 There is no server-side nav list, and there must not be one
`server/src/utils/navOverrides.js` (lines 13–21) refuses this explicitly:
> **What this module cannot check, deliberately: whether a `to` exists.** The three base NAV arrays
> are client constants (SiteHeader.jsx, AdminLayout.jsx, PlayerPortalLayout.jsx). Shipping a copy of
> them to the server would create a second source of truth for navigation that drifts the first time
> a route is added…
Confirmed: `export const NAV` lives in `client/src/components/SiteHeader.jsx:23`,
`client/src/routes/admin/AdminLayout.jsx:56`, `client/src/routes/player/PlayerPortalLayout.jsx:41`.
The server validates override *shape* and nothing else.
**Resolved:** nav registration is **client-side**, performed by the module's own client bundle
against a core-provided registry. No server nav API is introduced, and
`THEMING_AND_NAV.md`'s override model is untouched. The resulting pipeline is:
> registered defaults (core + modules) → role/feature filtering → admin overrides → rendered nav
### 1.4 Module nav items interleave into *core* groups
Appending a "UO" group is not enough. Today's UO items sit inside core groups in `AdminLayout.jsx`:
group **Moderation** holds `/admin/shard-ops` and `/admin/houses`; group **System** holds
`/admin/shard`, `/admin/shard-visibility`, `/admin/shard-atlas`; the unnamed footer group holds
`/admin/characters`. `MOD_PATHS` (line 109) additionally hardcodes two UO paths as
moderator-visible.
**Resolved:** nav registration takes a target group and order (`{ group: 'Moderation', order: 30 }`),
and `MOD_PATHS` becomes a `roles`-derived computation rather than a path allowlist.
### 1.5 The public nav's feature-gating mechanism is itself a shard system
Ten of the sixteen entries in `SiteHeader.jsx`'s NAV carry a `feature:` key (`status`, `champs`,
`guilds`, `governors`, `houses`, `ruleset`, `atlas`, `leaderboards`, `market`), resolved by
`useShardFeatures()` against `/api/v1/public/shard/features` — the shard visibility system.
Extracting the module removes the provider that core's own nav filter depends on.
**Resolved:** core keeps a generic feature-flag context with a **registerable provider**; the module
registers its `useShardFeatures` for its own namespace. No core nav item carries a `feature` today,
so with no module installed the filter is a correct no-op.
### 1.6 There is no migration system to model a module migration runner on
`server/db/schema.sql` is a single idempotent file — 1,380 lines, 67 tables — replayed in full on
every boot by `ensureSchema()` (`src/utils/db.js:48`), split on `;` and executed statement by
statement. Schema evolution uses `ALTER TABLE … ADD COLUMN IF NOT EXISTS` / `MODIFY COLUMN`
(from line 1322). There is **no version table, no runner, no migrations directory**.
A module-scoped migration runner would therefore be the *first* migration system in the codebase,
and would leave core and modules on two different schema models.
**Resolved:** modules ship a `schema.sql` fragment, replayed idempotently by the same
`ensureSchema()` immediately after core's. Forward-only falls out for free — it is all an idempotent
replay can be. Install and upgrade become the same operation. Uninstall stops the fragment being
replayed; **purge** is a separate, explicit, destructive admin action that runs the module's
`purge.sql`. A real migration runner, covering core *and* modules together, is a legitimate future
workstream; it is not a prerequisite for this one.
Twenty-seven of the tables move with the module: the 26 `shard_*` tables plus `uo_link_config`.
(This said 25 when written; the working tree was recounted in Phase 1 — see
[`MODULE_API.md`](MODULE_API.md) §6.4.)
### 1.7 Boot and shutdown is a lifecycle gap
`server/src/server.js` holds eight UO call sites that routes, nav and schema do not cover:
| Line | Call |
| --- | --- |
| 92 | `shardAtlas.refreshOnBoot()` |
| 99 | `shardClilocs.refreshOnBoot()` |
| 107 | `shardMarket.refreshDisplayNames()` (conditional on the cliloc import result) |
| 130 | `uoLinkSocket.start()` |
| 131 | `checkUoLink()` — plus the whole function at 147–169 |
| 179 | `uoLinkSocket.stop()` |
| 180 | `shardBroadcast.closeAll()` |
| 7–19 | five top-level `require`s of UO modules |
**Resolved:** the API surface includes `onBoot(ctx)` and `onShutdown()`, each individually
try/caught by the loader per §2.4.
### 1.8 Three core files are genuinely entangled
Everything else is a folder move. These are not:
1. **`src/config/notificationStreams.js`** — the push stream catalog. `mapShardEvent()` and most of
`STREAMS` are shard-derived, and it imports `PUBLIC_KINDS` from `utils/shardBroadcast`. Push
*infrastructure* is core; this *catalog* is module content.
→ `registerNotificationStreams(streams)`.
2. **`src/utils/pushDispatch.js`** — core infrastructure, but `fromShardEvent()` (line 112) requires
the `shardLinks` model (line 21) and `mapShardEvent` (line 23).
→ invert: `publish()` stays core, `fromShardEvent` moves into the module and calls it.
3. **`src/utils/announceWorker.js`** — the news dispatcher, with two delivery legs: Discord (core)
and town crier (module, via `uoLinkClient.postTownCrier`, line 36).
→ `registerAnnounceLeg({ leg, label, dispatch, classify })`.
`src/utils/newsGump.js` is module-side (news → in-game gump) and moves whole.
**Done in Phase 2 PR 4**, with core still the only registrant — the registries are
`src/modules/registries.js` and core goes through them by the same door a module will
(`registerCore()`, called explicitly from `app.js` before `modules.load()`). What each of the three
became:
1. Split in two. `config/coreStreams.js` is core's one stream (`news.post`, produced by the website's
own posts path); `config/shardStreams.js` is the other seven plus `mapShardEvent` and the
public-safety filter, and moves to module-uo whole. `registerNotificationStreams` lost its
`mapEvent` half — see [`MODULE_API.md`](MODULE_API.md) §2.4 for why that was a leftover, and what
follows for the public/personal split.
2. Inverted. `pushDispatch.js` is `publish` + `isAllowedEndpoint` and nothing else;
`utils/shardPush.js` holds `fromShardEvent` and is what `shardIngest` now calls.
3. Legs became registrations, and per-leg **rows**. The `towncrier_*` / `discord_*` column groups on
`announce_jobs` could never have held a module's leg — a module cannot `ALTER` a core table — so
they became `announce_job_legs`, backfilled and dropped in the same idempotent replay. The worker
no longer contains the word "towncrier": it iterates whatever is registered.
The residue in core is a one-time backfill block in `schema.sql`, deletable once every deployment has
booted it, and the two lines of `registerCore()` that Phase 3 turns into module-uo's `register()`.
### 1.9 A fourth mount shape: module routes under a core resource
`router/v1/admin/users.router.js` mounts `usersShard.controller.js` at six UO sub-paths of a **core**
resource — `/:id/shard/accounts|sales|houses|online|standing` and `DELETE /:id/shard/link/:account`.
And `GET /api/v1/admin/users/:id` (line 159) is itself served by `usersShard.getUser`, which is core
semantics that ended up in the UO controller by proximity.
**Resolved, two parts:** (a) `getUser` moves back into `admin.controller.js`; (b) core declares a
narrow **extension slot** on `/admin/users/:id` that the module mounts into, so core never learns
what "shard" means and all six URLs are preserved. Only core may declare an extension slot; a module
may not invent one.
**Both done in Phase 2 PR 4**, with core filling its own slot: the six paths are
`router/v1/admin/usersShard.router.js`, registered into `admin.users.detail` by `registerCore()`, and
Phase 3 changes the registrant rather than the routes. The slot's router is created at declare time
and filled later, because `users.router.js` is required while `app.js` is still being built. It is
mounted **last** on the resource, so core wins any path conflict by first-match.
One consequence was not foreseen and is worth the warning: **a slot is invisible to static analysis.**
There is no literal mount for `swagger-autogen` to follow, so the move silently deleted all six paths
from `swagger-output.json` while printing `Success`. The OpenAPI build now merges a generated
fragment per filled slot — [`MODULE_API.md`](MODULE_API.md) §6.6.
### 1.10 The Discord bot has no UO logic
The draft listed the bot's "UO-specific event/moderation logic" as an extraction candidate. Grepping
`website/bot/src` for `uo|ultima|shard|towncrier|governor|vendor` returns **zero matches**. The bot's
only site coupling is `src/site/siteApiClient.js`. There is nothing to extract.
### 1.11 The installer is not, and will not become, the delivery path
Two independent reasons, and the decision is that website and installer stay independent:
1. **The installer runs on the shard host and never contacts the website** — `src/cli.rs:162`: *"The
installer never contacts your website, never deletes anything from your…"*. A website module is a
website-host artifact.
2. **`Bundle` is hardcoded to exactly two components.** `installer/src/bundle.rs` declares
`pub link: LinkComponent` and `pub overlay: OverlayComponent`, both non-`Option`, alongside a
single top-level `protocol: u32` and `SUPPORTED_SCHEMA: u32 = 1`. A third artifact type would be
a schema-2 bump — and the sidecar/overlay protocol number has nothing to say about a website
module anyway.
Note also that **there is no SHA256SUMS trust anchor** anywhere in the installer, contrary to the
draft. The real model is a per-asset `sha256` field inside a bundle JSON fetched anonymously over
HTTPS from the `bundles` branch. No signatures. The *shape* is worth reusing; the name was wrong.
### 1.12 Modules must mount synchronously, from the filesystem
`server/scripts/routeManifest.js:38` and `server/swagger/swagger.js:29` both walk the Express stack by
`require`-ing `src/app.js` **with no database connection** — the manifest script deliberately points
the pool at a dead port. A DB-driven async loader would make module routes invisible to both,
silently breaking the frozen-URL-surface test and shipping undocumented routes.
**Resolved:** the **filesystem is the mounting source of truth.** `app.js` synchronously scans
`modules/*/module.json` at require time and mounts what it finds. The `installed_modules` row carries
state and metadata (version, installed-at, `startup_failed` reason, admin enable/disable) and is
reconciled against the filesystem once the DB is up. A module disabled in the DB is skipped by a
one-line dispatch guard rather than being unmounted, so the URL surface stays deterministic and
generatable.
### 1.13 The client seam is a route registry, not an admin-panel loader
`client/src/App.jsx` is a flat 235-line static route table, and UO routes appear in all three areas —
public (`/site/shard`, `/site/shard/activity`, `/site/governors`, `/site/houses`, `/site/atlas`,
`/site/atlas/:slug`, `/site/market`, `/site/market/vendors/:serial`, plus champs, guilds, rules,
leaderboards), admin (`shard`, `shard-visibility`, `shard-atlas`, `shard-ops`, `houses`,
`characters`, `characters/:serial`) and player. Nav is one consumer of that registry, not the
mechanism itself.
### 1.14 Production is a prebuilt, pull-only image — and that is the binding constraint
`website/Dockerfile` bakes `client/dist` at image build time, and `docker-compose.yml` has no
`build:` stanza at all (deliberately: *"a production host can only ever pull, never accidentally
build"*). Combined with the requirement that **an operator must never build anything to deploy a
module**, this rules out build-time inclusion of module client code, which the draft had as its
default.
It also rules out import maps as the shared-dependency mechanism: `config/csp.js:49` sets
`'script-src': ["'self'"]` with no `'unsafe-inline'`, and an import map must be an inline
`<script type="importmap">`.
**Resolved** — see §2.6. The path that survives all three constraints is: the module's CI ships a
**prebuilt ESM chunk**, core hands it React through a **global** rather than an import map, and
`htmlShell.js:111` injects a **same-origin** `<script type="module" src>`, which `'self'` already
allows.
---
## Part 2 — The plan
### 2.0 Scope and non-goals
Out of scope unless Phase 1 turns up a concrete reason otherwise: hot module reload; sandboxing
beyond the boundary stated in §2.2; inter-module dependency resolution; a module marketplace or
discovery UI; automatic data rollback beyond the forward-only model in §1.6. Install and uninstall
require a controlled **restart** — never a rebuild.
Added: **no installer changes at all** (§1.11), and **no Android changes in this workstream** beyond
the one consequence recorded in §2.8.
### 2.1 The API surface, derived from real dependencies
Taken from what the UO code actually imports today. Nothing speculative — if module-uo does not use
it, it is not on the list.
**Server — the `ctx` handed to a module's entry point**
| Member | Backed by | Why it is here |
| --- | --- | --- |
| `ctx.db` | `utils/db` (`query`, `pool`) | every `*.db.js` |
| `ctx.settings` | `model/settings/settings.model` | `shardIngest.js:20` |
| `ctx.log(namespace)` | `utils/logger` | all nine UO utils |
| `ctx.auth` | `utils/auth` | `shardVisibility.js:26` |
| `ctx.push.publish()` | `utils/pushDispatch` | `shardIngest.js:22` |
| `ctx.secretBox` | `utils/secretBox` | `uoLinkConfig` model |
| `ctx.middleware` | `requireAuth`, `requireRole`, `siteMode`, `validate` | every UO router |
| `ctx.uploads` | `admin/imageUpload.js` | atlas art import |
| `ctx.posts` | `model/posts/posts.model` | `newsGump.js`, announce legs |
**Server — what a module registers**
`registerRoutes(mounts)` (§1.2) · `registerExtension(slot, router)` (§1.9) ·
`registerNotificationStreams({ streams, mapEvent })` (§1.8) ·
`registerAnnounceLeg({ leg, dispatch, classify })` (§1.8) · `onBoot(ctx)` / `onShutdown()` (§1.7).
**Client — what a module registers**
`registerRoutes({ public, admin, player })` (§1.13) ·
`registerNav({ nav, group, order, feature })` (§1.3, §1.4) ·
`registerFeatureProvider(namespace, hook)` (§1.5).
**The acceptance test for the whole contract:** `module-uo` runs with **zero** `require`/`import`
reaching outside its own directory. Any gap extends the surface *before* extraction proceeds.
### 2.2 What the module boundary is, and is not
A module runs in the same Node process with full access. The boundary is a **code-organisation and
distribution boundary, not a security boundary** — which is fine for a self-hosted operator
installing software they chose, the same trust category as running its schema fragment. What makes
it worth having is that modules interact with core through a *defined* surface, so a core refactor
cannot silently break a module. Hence the zero-internal-imports rule above, enforced in CI rather
than by review.
### 2.3 Module packaging — one repo, one bundle
**`RunicGateway/Module-uo`** — `https://gitea.whitlocktech.com/RunicGateway/Module-uo.git`, note the
capital `M`, matching `Android-app`'s casing rather than the lowercase directory name. The repo
exists but is **empty** as of 2026-08-10: no branches, no initial commit. Its first commit needs the
usual scaffolding — `README.md`, `LICENSE.md` (GPL-3.0-or-later), `CONTRIBUTING.md` with the
AI-disclosure clause, the PR template, and CI.
Server and client halves live side by side and version together, so a route and the screen that
calls it can never be mismatched:
```
RunicGateway/Module-uo
module.json id, version, coreApi range, mounts, extensions
server/ routers, controllers, models, utils
server/db/schema.sql fragment replayed by ensureSchema()
server/db/purge.sql destructive, only ever run by an explicit purge
client/src/ route components, nav registrations, feature provider
client/dist/ PREBUILT ESM chunk, published by module CI
```
Release artifact: `module-uo-<version>.tar.gz` plus a manifest carrying its `sha256`.
The module's **id** is `uo` — that is what appears in `module.json`, in `installed_modules`, in the
`modules/<id>/` path and in the URL segment. `Module-uo` is the repository; `module-uo` elsewhere in
this document names the module and its artifact, not the repo.
`module.json` declares a `coreApi` semver range, checked at boot against a `MODULE_API_VERSION`
constant in core; a mismatch fails **loudly** rather than silently. This is a separate number from
`PROTOCOL_VERSION`, which versions the shard wire and says nothing about a website module.
### 2.4 The module state machine
`installed → enabled → started`, with `disabled` and `startup_failed` as recoverable states.
**A module that fails to load must never take the site down.** The loader catches failures across the
module's entire lifecycle — require, schema fragment, router construction, registration calls,
`onBoot` — not merely those that surface after a router object was returned. Any failure at any point
marks that one module `startup_failed`, records the reason, and the site comes up with that module's
routes and nav absent. `startup_failed` is recoverable from the admin panel — disable, retry, or roll
back to the previous version — with no shell access to the box.
**Where the states live.** One `installed_modules` row per module, keyed by its id, with the machine
held in a single `state` column carrying all five values — the shape this section already describes,
rather than a policy flag beside a runtime one. The table also carries `name`/`version` for the admin
screen, `failure_stage` + `failure_reason` for [`MODULE_API.md`](MODULE_API.md) §4.4's recorded
reason, `source` + `sha256` for the
install provenance of §2.5 below (both null for a directory placed on the volume by hand, which stays
supported), and `installed_at` / `started_at` / `updated_at`. Full column list in
[`BACKEND_DESIGN.md`](BACKEND_DESIGN.md) §3.
**The row is a record of what happened, never the source of truth for what is mounted.** The loader
scans the filesystem at require time, before the database is reachable (API §4.1), so the URL surface
is a property of the volume and not of a row here. What the row decides is whether a mounted module
*answers* (`disabled` ⇒ its guard 404s, API §4.5) and what the admin panel shows after a failure.
This is also why
`routes.manifest.json` can be generated against a dead database.
**`disabled` is the only state a boot leaves alone.** Every boot resets each non-disabled row to
`enabled`, clearing any recorded failure, and the load that follows writes this boot's outcome —
`started` or `startup_failed`. Three consequences, all deliberate:
- **A `startup_failed` module is retried on every restart.** An operator who fixes the underlying
cause — a truncated file, a missing dependency, a database that was not up yet — gets the module
back by restarting, with no admin-panel visit. The cost is that a deterministically broken module
re-records its failure each boot, which is the honest thing for it to do.
- **A stale reason can never be shown against a running module**, because every non-failing
transition clears the failure columns.
- **Disabling is an operator decision, not an outcome**, so it survives restarts untouched — and a
module the operator switched off is neither started nor re-recorded as failed if it happens to be
broken. `installed` is likewise transient: it is the gap between an install writing the row and the
restart that resolves it.
A re-install or an upgrade refreshes `name`/`version`/provenance and deliberately leaves `state`
alone: upgrading an enabled module must not silently switch it off, and re-installing a disabled one
must not silently switch it on.
**A row whose directory is gone is marked `startup_failed`** (stage `require`, reason "module
directory not present on the volume"), settled with PR 5. The boot reset above has just moved it to
`enabled`, and a row claiming to be enabled for a module that is not on the volume is the one state
that is simply untrue — it would be read that way by the admin panel and by
`GET /api/v1/public/modules` alike. This catches only a directory deleted by hand: an uninstall
leaves the row `disabled`, which the reset never touches.
### 2.5 Install, uninstall, purge
Modules live on a **mounted volume**, not in the image — the same treatment `uploads` already gets in
`docker-compose.yml`. That is what makes the WordPress model work against a pull-only image.
**Install:** admin selects the module → bundle downloaded from the module repo's release and verified
against its `sha256` → unpacked into `modules/<id>/` on the volume → `installed_modules` row written →
**restart**. On boot the loader scans the filesystem (§1.12), validates prefixes, mounts, replays the
schema fragment, runs `onBoot`, and each module reaches `started` or `startup_failed`.
Nothing is compiled at any point. The operator restarts; they never build.
**Uninstall** (default, non-destructive): row set to `disabled`, directory removed, restart. The
module's tables and data are **retained**. **Purge** is a separate, explicit, destructive action that
runs `purge.sql`; it is never bundled into uninstall.
**Surfaces:** the admin panel, and the Docker environment under `website/` — a declarative module set
resolved at container start from the mounted volume, so a compose-managed host is not driven by
clicking. Both paths write the same `installed_modules` row and neither requires a build step.
### 2.6 How the client half loads
This is the piece §1.14 constrains hardest. Three requirements had to hold at once: the operator
builds nothing, production pulls a prebuilt image, and `script-src 'self'` forbids inline script.
1. **The module's CI builds its client half** with Vite in library mode, declaring `react`,
`react-dom` and `react-router-dom` as **externals**. The module never bundles its own React —
there is exactly one React instance, owned by core.
2. **Core exposes the shared dependencies on a global** before mount — `window.__rg = { react,
reactDom, router, registry }` — and the module's externals resolve to it. A global, not an import
map, precisely because an import map must be inline and CSP forbids that.
3. **`htmlShell.js` injects the module's entry script.** It already rewrites `</head>`
(`utils/htmlShell.js:111`), so this is an extension of a working mechanism, not a new one. The tag
is `<script type="module" src="/modules/uo/entry.js">` — same-origin, so `'self'` passes with no
nonce and no inline.
4. **The SPA reads `/api/v1/public/modules`** to learn what to load, then registers routes, nav and
its feature provider through `window.__rg.registry`.
Phase 1 prototypes exactly this before anything is committed to it (§2.7).
### 2.7 Phases
**Phase 0 — unblock CI and scaffold the repo.** Land the one-line `pr-checks.yml` trigger fix on
`website` `main` (§2.9), cut `edge` from `main`, and give `Module-uo` its initial commit (§2.3).
Nothing else can be trusted until the first of these is done.
**Phase 1 — API contract + spike (blocking).** Merge this document. Write the contract at
[`docs/website/MODULE_API.md`](MODULE_API.md) — **done**; it amends this document in four places,
listed in its Part 6, one of which (OpenAPI generation, §6.1 there) needs a decision before Phase 2
starts. Then a throwaway spike on an unmerged branch moving **`/api/v1/public/atlas/*`** behind the
proposed surface — the smallest honest test: six routes, DB-backed, no sidecar, no SSE, one boot
hook. The spike must *also* prove the §2.6 chunk load end to end, since that is the highest-risk
decision in the plan. Exit criteria: no internal-file imports, `npm run routes:manifest` produces a
zero-line diff, and the chunk loads under the enforced CSP.
**Phase 1 is complete.** The spike ran on `website` branch `spike/module-atlas` (cut from `edge`,
never merged) and **met all three exit criteria** — see [`MODULE_API.md`](MODULE_API.md) Part 7. §2.6
survives intact: the prebuilt chunk loads and renders under `script-src 'self'` with zero violation
reports. The one thing it changed is that §2.6's one-React rule turns out to have a server-side twin
nobody had written down — a module cannot resolve core's `express` either, so core hands that over
too (API §7.2).
**Phase 2 — Core scaffolding, no behaviour change.** One PR each, in order:
1. `installed_modules` table + the §2.4 state machine.
2. `src/modules/loader.js` — synchronous filesystem scan, manifest validation, prefix-collision
rejection, per-module try/catch across the whole load path, mounting into the tier routers.
3. `ensureSchema()` extended to replay module fragments after core's.
4. The three de-entanglement registries (§1.8), with core still the only registrant.
5. Boot/shutdown hook dispatch in `server.js`, likewise.
6. `GET /api/v1/public/modules` — installed ids, versions and capabilities, shaped like the existing
branding/site-settings endpoint. The SPA needs it to know what to load; the Android plan consumes
the same endpoint.
7. Client `src/modules/registry.js`, the `window.__rg` shared-dependency global, and the
`htmlShell` script injection — empty registry, no visible change.
8. `MOD_PATHS` → `roles`-derived (§1.4); the generic feature-provider seam (§1.5).
9. `docker-compose.yml` gains the `modules` volume.
Exit criterion: `routes.manifest.json` diff is zero lines and every existing test passes. If Phase 2
changes one URL, it is wrong.
**Progress: PRs 1-5 done.**
- **PR 1** — `installed_modules` and the state machine, with the stored shape and the boot rules
settled in §2.4 above.
- **PR 2** — `server/src/modules/loader.js`: the filesystem scan, manifest validation, prefix and
table-name collision rejection, per-module try/catch and the tier mount, behind the §4.5 dispatch
guard. Two decisions landed with it, both recorded in [`MODULE_API.md`](MODULE_API.md): the load
trigger is **one explicit `modules.load(tierRouters)` call in `app.js`**, never a lazy scan
(API §7.6); and the "does core own this prefix" check **probes the live tier routers** rather than
a hardcoded table, so it cannot drift when core adds a capability router (API §4.3). The three
de-entanglement registries and the two lifecycle hooks throw `not available until phase 2 PR 4/5`
rather than no-op — an accepting stub would let a module believe it had registered something.
28 tests, all on the failure paths.
- **PR 3** — schema fragment replay. `ensureSchema()` replays each installed module's fragment after
core's, with the statement splitter extracted to `utils/sqlStatements.js` so both are split by the
same code. The decision that shaped it, recorded in [`MODULE_API.md`](MODULE_API.md) §2.6: the
fragment is **validated at load time and executed later**, split on whether a database is needed to
know the answer — a fragment breaking a stated rule never mounts, while a failure only the server
could report (a bad column type) is post-mount and 503s. The rules are enforced as a **leading-verb
allowlist** (`CREATE`, `ALTER`, `INSERT`, `UPDATE`) rather than the `DROP` denylist §2.6 words them
as, because the file is replayed on **every boot**. Found while wiring it: `npm run seed` calls
`ensureSchema()` without ever requiring `app.js`, so the replay has to tolerate an unscanned loader.
- **PR 4** — the three de-entanglement registries, `src/modules/registries.js`. Core's own streams,
its Discord announce leg and its users-detail routes all go through them, so the seams are
exercised on every boot before a module depends on them; §1.8 and §1.9 above record what each
became. Four decisions landed with it, all recorded in [`MODULE_API.md`](MODULE_API.md): announce
legs became a **child table** rather than waiting for Phase 3 (§2.4 — a module cannot alter a core
table, so a registered leg had nowhere to live); **`mapEvent` dropped** from the stream registry
(§2.4 — a leftover from before the push inversion was settled); **core registers through the same
staging area a module uses**; and core's six shard sub-paths **moved behind the slot now** rather
than in Phase 3.
Registering is **validate-then-commit**: the loader stages a module's claims and the second pass
commits them, so a module that throws halfway through `register()` — or fails a later validation
step — leaves nothing behind. That is the registry-side twin of PR 2's second-pass mount rule.
Two build tools needed teaching, both because a mechanism this PR introduced is one they had never
seen. `scripts/routeManifest.js` could not decode a **parameterised mount**: its unwinder expected
a group shape express does not emit, and the branch had never run. It threw rather than guessing,
which is exactly what it is for. And `swagger-autogen` could not follow a route into an extension
**slot**, deleting 407 lines while reporting success; the fix is the fragment merge core owed
anyway ([`MODULE_API.md`](MODULE_API.md) §6.6).
There is still no module on the volume and no boot wiring, so this changes nothing an operator or a
client can see: **884 tests pass** and `routes.manifest.json` is unchanged at 229 routes. The two
lines of OpenAPI that do move are the retry endpoint's summary and its `leg`, which is no longer a
fixed enum because the leg set is whatever has been registered.
- **PR 5** — boot/shutdown dispatch and the `installed_modules` reconcile, `src/modules/lifecycle.js`.
`api.onBoot`/`api.onShutdown` stop throwing, `server.js` gains one call on each side, and the
§2.4 machine finally runs against real outcomes — which is what makes §4.5's `disabled` 404 leg
reachable for the first time. Four decisions landed with it, all recorded in
[`MODULE_API.md`](MODULE_API.md) §2.5 and §4.4: **the loader classifies its failures** by §4.3 step,
so `failure_stage` says where a module broke instead of being a column nothing filled; **a row whose
directory is gone is marked failed** rather than left claiming `enabled` (§2.4 above); **core's eight
UO boot call sites stay in `server.js`** until Phase 3, because unlike a registered announce leg a
boot call site already has somewhere to live and moving it now would be extraction done early in a
phase whose exit criterion is that nothing changes; and **`onBoot` gets no timeout** — shutdown races
a SIGKILL and boot does not, and a slow `onBoot` delaying the listener is the contract's promise to
a module that must warm up before it serves.
The dispatch lives outside the loader for the reason the schema replay does: the loader is required
by `app.js` against a dead pool, and this half is database-first. They meet at one function,
`loader.setState()`, so the in-memory record the dispatch guard reads and the row the admin panel
reads cannot drift apart.
Still nothing on the volume: **900 tests pass**, `routes.manifest.json` is unchanged at 229 routes
and the OpenAPI spec regenerates byte-identical.
**Phase 3 — Extract `module-uo`.** Moves out of `website/`: the 8 model directories and their 25
tables; the nine UO `utils/` files plus `newsGump.js`; the 13 router/controller files;
`scripts/importSpawnAtlas.js` and `db/spawnAtlas.art.json`; `usersShard.controller.js` **minus
`getUser`** (§1.9); the shard-derived half of `notificationStreams.js` and the town-crier leg of
`announceWorker.js`; and on the client, roughly twenty route components, their nav registrations and
`useShardFeatures`.
Acceptance, all four required:
1. **Zero UO identifiers in core** — no `shard`, `uoLink`, `cliloc`, `atlas` or `towncrier` outside
`modules/`. Enforced by a CI grep test, not by review.
2. **Zero internal-file imports** from `module-uo` into core.
3. **`routes.manifest.json` API diff is zero lines**, except the deliberate `GET /admin/users/:id`
ownership move, which changes no URL. After extraction the core manifest no longer contains UO
routes — `module-uo` generates and freezes its own in its own repo.
4. **A written `module-rust` dry run** — manifest, mounts, nav entries, one notification stream — not
implemented, to prove the contract generalises before more is built on it.
**Phase 4 — Delivery.** The admin-panel Modules screen (install, enable, disable, retry, purge,
`startup_failed` with its recorded reason) and the Docker-environment path from §2.5. Deliberately
last, so loader, packaging, schema and chunk-loading problems are not all being debugged at once.
**Phase 5 — The Integration Kit.** `RunicGateway/Integration-kit`, the instruction book for building
a module for a game that is not UO — the website module, the sidecar and why it exists, and the
game-side plugin that feeds it. Scaffolded when Phase 2 lands, written against Phase 3's extraction,
finished alongside Phase 4. Full shape and its acceptance test in §2.11.
### 2.8 SPA URL namespacing — a deliberate break
**Decision: module pages are namespaced, and old paths are not redirected.** The site is not public
yet, so bookmarks, inbound links and configured nav overrides carry no real weight. This buys a
visible boundary in the URL rather than a hidden one.
The rule is that a module owns one path segment wherever it appears:
| Today | After |
| --- | --- |
| `/site/shard`, `/site/atlas`, `/site/market`, `/site/governors`, … | `/uo/shard`, `/uo/atlas`, `/uo/market`, `/uo/governors`, … |
| `/admin/shard-ops`, `/admin/shard`, `/admin/shard-visibility` | `/admin/uo/shard-ops`, `/admin/uo/link`, `/admin/uo/visibility` |
| player shard screens | `/player/uo/…` |
**API URLs are not affected** — they keep their exact paths per §1.2, so the Android app and the
Discord bot need no change for the API.
Two consequences, both accepted:
- **Saved nav-override rows are keyed by `to`** (`utils/navOverrides.js`), so any stored
`nav_public` / `nav_admin` / `nav_player` customisation stops applying and must be redone. No
migration is written.
- **`android-app/.../ui/navigation/NavPaths.kt` maps SPA paths to native screens** and holds ten
`/site/*` constants that will no longer resolve. That is one small Android PR, folded into the
separate Android module plan. App Links verification itself is unaffected — the manifest's intent
filters only cover `/mobile/callback` and `auth/callback`.
### 2.9 Branch strategy — `edge`, then one cutover
All website work lands on an **`edge`** branch and reaches `main` as a single cutover at the end,
the same shape used for [protocol v3](../link/v3.md) and the Android theming workstream. Nothing
half-extracted is ever on `main`: a core that has grown a module loader but not yet lost its UO code
is a coherent state, and a core mid-extraction is not.
`edge` does not exist on `website` today — the protocol v3 cutover landed and the branch was cleaned
up, so it is cut fresh from `main`. `Module-uo` develops on its own `main` from its first commit;
it has no cutover to perform, since nothing depends on it until the website cutover lands.
**Phase 0, and it blocks everything: the CI trigger.** `website/.gitea/workflows/pr-checks.yml`
declares:
```yaml
on:
pull_request:
branches: [main]
```
So a PR into `edge` runs **no checks at all** — no server tests, no client build, no bot install.
This is the same trap that let all nine Android M12 phase PRs merge with zero CI. It matters more
here than it did there, because Phase 2's exit criterion *is* a CI result: a zero-line
`routes.manifest.json` diff and a passing test suite. Running the whole workstream blind and
discovering the breakage at cutover is the expensive version of this.
The fix is one line — `branches: [main, edge]` — and it must land on `website` `main` **before** the
first module PR, not alongside it. `build-images.yml` is untouched: it triggers on push to `main`, so
images are published and production rolls at the cutover and at no point before it, which is correct.
### 2.10 Process obligations
Every server-side PR runs `npm run swagger`, `npm run routes:manifest` (the diff is reviewed, not
merely regenerated) and `npm test`, and carries a matching edit to `BACKEND_DESIGN.md`. Module
documentation aggregates in this repo under `docs/modules/<id>/` rather than living in module repos.
Conventional Commits, the AI-disclosure trailer, branches cut from an up-to-date `main`.
### 2.11 The Integration Kit — the instruction book for building a module
**`RunicGateway/Integration-kit`** — `https://gitea.whitlocktech.com/RunicGateway/Integration-kit.git`,
**empty as of 2026-08-10**: no branches, no initial commit, exactly where `Module-uo` was at the start
of Phase 0. Its first commit needs the same scaffolding as any other repo here — `README.md`,
`LICENSE.md` (GPL-3.0-or-later), `CONTRIBUTING.md` with the AI-disclosure clause, the PR template.
**Who it is for.** Everything else in this plan is written for someone changing *this* system. The kit
is written for someone building a **new** one: a person who wants Runic Gateway to front a game that
is not Ultima Online, starting from nothing. It is the only document in the project whose audience is
outside the org, and that changes how it is written — it explains and motivates rather than records
decisions.
The job spans all three layers of the data path, which is why it is one book and not a page in each
repo:
1. **The website module.** `module.json`, the server entry point and what `ctx` hands you, the
`register*` calls, the schema fragment, the prebuilt client chunk and the shared-dependency rule,
packaging and release CI. The bulk of it.
2. **The sidecar** — what it is and, more importantly, *why*. The shard is never network-reachable;
the shard dials **out** and the sidecar is the listener; the wire is a versioned compatibility
contract rather than a build dependency; only the website's backend talks to it. A new game needs
its own sidecar or an adapter into the existing one, and neither can be designed by someone who has
been handed the message list and none of the reasoning.
3. **The game-side plugin** — how a shard feeds the sidecar without ever letting the sidecar stall the
game: the bounded drop-oldest queue, the dedicated writer thread, world reads only on the game's
own thread. `servuo-plugins/` is the worked example; the constraints are general, and a plugin that
ignores them takes the game down when the sidecar wedges.
**The rule that keeps it from rotting: the kit never re-specifies a contract.**
[`MODULE_API.md`](MODULE_API.md) stays normative for the module surface, and
[`../link/PLAN.md`](../link/PLAN.md) + [`../link/INTEGRATION.md`](../link/INTEGRATION.md) for the wire
protocol. The kit *teaches* — worked examples, the reasoning, the order to do things in, the mistakes
that cost time — and links out for the authority. Where it must show a member list it quotes with a
pointer, never a copy. A guide that restates a contract diverges from it silently, and a reader who
follows the divergent copy gets a module that fails validation for reasons the guide cannot explain.
**It cannot be written before the contract is proven**, so it trails the implementation rather than
leading it:
- **Scaffolded once Phase 2 lands** — repo, license, CI, and an outline. By then a loader exists to
describe and a real module to point at.
- **Written against Phase 3's extraction**, using `Module-uo` as the worked example throughout. A kit
whose examples are invented is a kit whose examples do not compile.
- Phase 3's fourth acceptance criterion, the written `module-rust` dry run, is really this book's
first chapter — and doubles as the honest test that the contract generalises past its first module.
**Acceptance:** someone builds a trivial working module for a second game by following the kit alone,
without reading core's source. Until that has happened it is a draft, however finished it looks.
The org landing page (`RunicGateway/.profile`) and the workspace's `CLAUDE.md` repo table both gain a
row for it — when it has content, not while it is an empty repo.
---
## Part 3 — Settled decisions
| # | Decision | Where |
| --- | --- | --- |
| 1 | Modules ship idempotent `schema.sql` fragments; no migration runner is built | §1.6 |
| 2 | Website and installer stay independent; delivery is website-side only | §1.11, §2.5 |
| 3 | Core declares an extension slot on `/admin/users/:id`; all six URLs preserved | §1.9 |
| 4 | Phase 1 spike targets `/api/v1/public/atlas/*` | §2.7 |
| 4a | The contract lives in [`MODULE_API.md`](MODULE_API.md); it is normative where the two differ | §2.7 |
| 4b | Modules ship an OpenAPI **fragment**; core merges started modules' fragments into `/api/docs.json` | API §6.1 |
| 4c | Core exposes a **curated, closed** UI kit + request primitive on `window.__rg`, versioned by `MODULE_API_VERSION` | API §3.4 |
| 5 | Install surfaces: admin panel and the Docker environment; never a build step | §2.5 |
| 6 | One repo, one bundle — server and client halves version together | §2.3 |
| 7 | Android app is a separate plan; core owes it `/api/v1/public/modules` | §2.5, §2.7 |
| 8 | SPA pages namespaced: `/uo/*`, `/admin/uo/*`, `/player/uo/*` | §2.8 |
| 9 | Clean break — no redirects, no nav-override migration; site is not public yet | §2.8 |
| 10 | Client half loads as a prebuilt ESM chunk with React shared via a core global | §2.6 |
| 11 | Website work lands on `edge` and reaches `main` as one cutover at the end | §2.9 |
| 12 | The module repo is `RunicGateway/Module-uo`; the module id is `uo` | §2.3 |
| 13 | `RunicGateway/Integration-kit` is the module-builder's instruction book — module + sidecar + game plugin, teaching only, never re-specifying a contract | §2.11 |

View File

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

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.1–4.5 are blocking; §4.6–4.11 are scope corrections.
### 4.1 There is no way to delete a setting
The entire "Reset to defaults deletes the row" principle — which all five new
keys rely on, and which the doc lists as an acceptance criterion — has no
implementation.
[`settings.db.js`](../../website/server/src/model/settings/settings.db.js)
exposes `get` / `getAll` / `set` / `seedDefault` only, and the admin API is
`PUT /admin/settings` taking a key/value object
([`admin.controller.js:499`](../../website/server/src/router/v1/admin/admin.controller.js)).
**Fix:** add `settingsDb.remove(key)` and a `DELETE /api/v1/admin/settings/:key`
route with an explicit key allowlist (the five new keys plus `hero_layout_draft`).
Admin-only, same gate as the existing settings routes. Deleting a key that does
not exist is a success, not a 404 — "reset" is idempotent.
### 4.2 Non-admins cannot read their own nav overrides
The doc says `nav_admin` / `nav_player` are admin-only settings "fetched by the
authenticated `AdminLayout` / `PlayerPortalLayout`." But `GET /admin/settings` is
gated `requireRole('admin')`
([`settings.router.js:18,28`](../../website/server/src/router/v1/admin/settings.router.js)),
while `AdminLayout` renders for **editors and moderators** and
`PlayerPortalLayout` renders for **players**. Those users have no endpoint from
which to read the key, so their nav would silently never apply the override.
**Fix:** new `GET /api/v1/settings/nav`, `isLoggedIn` only, returning
`{ nav_admin, nav_player }`. Not in `PUBLIC_KEYS` — an anonymous visitor has no
use for either, and the admin nav's labels leak the shape of the admin surface.
### 4.3 `renderIndexHtml` runs once at boot, not per request
[`app.js:207`](../../website/server/src/app.js) reads and templates `index.html`
at module load and serves that one string for every SPA route forever. The doc
describes overriding `logo`/`favicon` as "an async settings read inside a
currently-synchronous-feeling builder" — it is actually a lifecycle change, not
just an `await`.
**Fix:** keep the rendered shell cached in a module-level variable, render it
lazily on first request, and invalidate on any successful write to
`brand_assets`. Two hard requirements:
- A DB fault must never fail the page — on a read error, fall back to the
env-only shell (the current behavior).
- The shell must stay a single cached string in the steady state. Do not do a
settings read per page view.
### 4.4 Settings values are strings, not objects
`settings.value` is `TEXT`
([`schema.sql:126`](../../website/server/db/schema.sql)) and JSON-valued keys are
stored `JSON.stringify`'d and parsed client-side — see `parseLayout` in
[`heroLayout.js:58`](../../website/client/src/lib/heroLayout.js). The doc's
`settings.brand_assets?.hero` and `settings.nav_public` read as if they arrive
parsed. They do not.
**Fix:** one shared `parseJsonSetting(str, validator)` helper, used by every
consumer. A malformed or wrong-shaped value is treated as **absent** (falls back
to env/code default), never as an error and never as a partial object. This is
the same fail-safe posture `parseLayout` already takes.
### 4.5 The Android app and Discord embeds are silently excluded
`getPublic().brand` is a **documented cross-repo contract**, not an internal
detail. [`publicBrand.test.js:30`](../../website/server/test/publicBrand.test.js)
locks its field list, and the Android app's `BrandDto` seeds the entire Material
theme from `brand.accent` (`MainActivity.kt:72` → `RunicGatewayTheme`), with
`logo` / `hero` / `favicon` fields alongside it. `brand.accentInt` — derived once
at boot — is what Discord embeds color themselves with.
If theme and asset overrides live only in the new keys, an admin changes the
accent on the website and **the phone app and the Discord bot keep the old one**.
**Fix (locked):** resolve the *effective* values server-side in
`getPublic()`'s brand block
([`settings.model.js:130-141`](../../website/server/src/model/settings/settings.model.js)):
```js
accent: themeVisual?.colors?.accent ?? brand.accent
logo: brandAssets?.logo ?? brand.logo
hero: brandAssets?.hero ?? brand.hero
favicon: brandAssets?.favicon ?? brand.favicon
```
The web client needs **no change** for this — its existing
`setProperty('--accent', brand.accent)` line
([`SiteContext.jsx:30-32`](../../website/client/src/contexts/SiteContext.jsx))
simply receives a better value. Consequences to handle:
- ~~`brand.accentInt` must be **recomputed from the effective accent** per
request rather than read from the boot-time constant, or Discord embeds
drift.~~ **Corrected in Phase 3 — this fix as written was a no-op.**
`getPublic().brand` never exposes `accentInt` (`publicBrand.test.js` asserts
it is `undefined`, deliberately: it is a Discord-only integer form), and the
server-side `brand.accentInt` has no consumer at all. Discord embeds are
colored by **`bot/src/brand.js`, in a separate process**, reading
`BRAND_ACCENT_COLOR` from env at boot — so there was nothing per-request to
recompute, and the drift the note describes was real but unfixable from the
server. What Phase 3 actually did: the bot now fetches
`GET /public/settings` → `brand.accent` (it already has a public-API client)
behind a 10-minute cached getter, keeping env as the fallback. See
"Phases 3–4 as landed" below.
- `publicBrand.test.js` gains cases: no rows → env values unchanged (the existing
assertions must still pass verbatim); `theme_visual` accent set → effective
accent returned; `brand_assets.favicon` set → favicon overridden while `logo`
and `hero` still come from env.
- The Android app needs **no change** to pick up accent/assets. Whether it should
also honor the full preset (radius, fonts) is a separate question for
`docs/android/PLAN.md`, out of scope here.
### 4.6 `spacingUnit` and `borderWeight` are not variable renames
The doc treats these as the same mechanism as color. They are not:
- **Spacing.** `theme.css` contains **zero** `calc()`-based spacings (the 5
`calc()` uses are all `width: min(…, calc(100% - 32px))` page shells). Every
padding is a hand-written non-multiple — `7px 14px`, `12px 26px`, `11px 14px`,
`13px 14px`. A density token that actually moves density means rewriting ~40
declarations into `calc(var(--space-unit) * n)`, and most of the app's real
spacing is inline JSX the token cannot reach anyway.
- **Border weight.** 39 hand-written `1px` borders, several of which are
*semantic* accents that must not scale with a density slider — `.note`'s 3px
left rule, `.page-quote`'s 3px, `.pb-tab`'s 2px active underline.
**Decision:** both are **cut from v1** and do not appear in the admin form.
Colors, fonts, radius and shadow depth cover "brand feel" cleanly; these two do
not, and shipping them as no-op fields would be worse than not shipping them.
### 4.7 Six radii cannot round-trip through three tokens
The doc's preset blocks set `--radius-card: 8px`, but the actual values in
`theme.css` are 14×`8px`, 4×`999px`, 4×`10px`, 1×`12px`, 1×`7px`, 1×`6px`. `.card`
and `.panel` are **10px** today and `.panel-flat` is **12px**. Adopting the doc's
three tokens verbatim would restyle every existing instance — including ones that
never touch the feature — which contradicts the acceptance criterion directly
above it.
**Fix (locked):** four tokens seeded at today's real values, so the promotion step
is genuinely a no-op:
```css
:root {
--radius-pill: 999px; /* .btn, .pill, .badge, .wiki-tag */
--radius-panel: 12px; /* .panel-flat */
--radius-card: 10px; /* .card, .panel */
--radius-input: 8px; /* .input, .textarea, .select, .btn-sq, .note, .rte, .prose img */
}
```
The 7px (`.rte-btn`) and 6px (`.rte-linkmenu-item`) values stay literals — they are
interior editor chrome, not brand surface. The preset blocks in §6 carry corrected
`--radius-card` values accordingly.
### 4.8 Parchment is a light-mode port, not a preset
`theme.css` carries 28 `rgba()` literals that assume a dark background — `.pill`'s
`rgba(11,22,48,0.5)` fill, `.note`'s background, all seven `.badge-*` fills, the
diff add/del colors, `.moon`'s radial gradient, `#dbe2ea` prose strong — plus the
hero overlay stacks `rgba(11,15,20,…)` hardcoded in `heroLayout.js` and four route
files, plus `rgba(9,13,18,0.86)` inline in `SiteHeader.jsx:55`. None of that
responds to a `[data-theme]` variable block; Parchment would inherit dark chrome
on a light background and look broken.
**Decision:** three dark presets in v1. Parchment becomes **Phase 9**, scoped as a
light-mode port with its own contrast pass across every component.
### 4.9 The hero already has a third override layer
`hero_layout.background.image_url` **already** beats `brand.hero`
([`heroLayout.js:39-50`](../../website/client/src/lib/heroLayout.js)). The real
resolution order is:
```
hero_layout.background.image_url → brand_assets.hero → BRAND_HERO → /assets/img/runic-emblem.png
```
The doc's two-link chain omits the existing top link. The admin UI must say so
explicitly, or "I uploaded a hero and the portal ignored it" becomes a bug report
against a working system.
### 4.10 Favicon `.ico` is not possible without weakening the upload path
`MIME_EXT` in
[`imageUpload.js:24-30`](../../website/server/src/router/v1/admin/imageUpload.js)
has no `image/x-icon` or `image/vnd.microsoft.icon` entry, and the stored
extension is derived from that map — which is exactly the property that makes the
upload path safe. The doc floats "`.ico`/`.png` only" for favicons; the `.ico`
half would mean adding a new file type to `/uploads`.
**Decision:** **PNG only** for favicons. `<link rel="icon">` accepts PNG in every
browser this app supports, and the allowlist is left untouched. A tighter size cap
than the shared 8 MB limit is applied at the route, not in the shared multer
config.
### 4.11 Smaller notes
- **CSP is already fine.** [`config/csp.js:50-51`](../../website/server/src/config/csp.js)
already allows `https://fonts.googleapis.com` in `style-src` and
`https://fonts.gstatic.com` in `font-src`. The font shortlist needs no CSP
change — which is worth stating, because widening CSP for a cosmetic feature
would not be worth it.
- **Do not touch the footer badge.**
[`SiteFooter.jsx:19`](../../website/client/src/components/SiteFooter.jsx) is the
hardcoded "powered by Runic Gateway" emblem. It is deliberately not the instance
logo and must not follow `brand_assets.logo`.
- **Nav labels do not reach the portal hero.** `hero_layout`'s
`default-quick-links` element duplicates News / Screenshots / Five on Friday /
Newsletter / About as its own buttons. Renaming those in the nav editor will not
rename them on the portal; they are edited in the hero editor.
- **The nav editor must refuse to hide its own entry.** Not a lockout — hiding is
presentation-only and the URL still resolves — but recovering by typing a URL is
a bad enough experience to be worth one guard.
- **Process, per `CLAUDE.md`.** Every server-side phase requires
`npm run swagger`, `npm run routes:manifest` (`routeManifest.test.js` fails
otherwise), and a matching edit to
[`BACKEND_DESIGN.md`](BACKEND_DESIGN.md). None of this is in the design doc.
## 5. Fonts: curated Google Fonts, not free text
`index.html` already loads Cinzel from Google Fonts, so this extends an existing,
already-trusted pattern rather than introducing a new one.
**The dropdown's value — not free text — is what is stored.** Each option's value
*is* the full CSS `font-family` stack exactly as it will be applied, so the client
does zero string-building from admin input and `theme_visual` stays a closed set of
known-safe values.
### 5.1 The shortlist
| Role | Option | Stored stack |
|---|---|---|
| **Serif body** | EB Garamond — strongest fantasy/historic | `'EB Garamond', Georgia, serif` |
| | Merriweather — excellent readability | `Merriweather, Georgia, serif` |
| | Playfair Display — elegant/editorial | `'Playfair Display', Georgia, serif` |
| | IM Fell English — strongest old-world/UO flavor | `'IM Fell English', Georgia, serif` |
| **Display heading** | Cinzel — current Runic Gateway identity | `Cinzel, Georgia, serif` |
| | Playfair Display — elegant alternative | `'Playfair Display', Georgia, serif` |
| | EB Garamond — softer/classic | `'EB Garamond', Georgia, serif` |
| | IM Fell English — very strong fantasy | `'IM Fell English', Georgia, serif` |
| **Sans UI** | Inter — default modern UI choice | `Inter, Arial, sans-serif` |
| | Work Sans — slightly more character | `'Work Sans', Arial, sans-serif` |
| | Source Sans 3 — extremely readable | `'Source Sans 3', Arial, sans-serif` |
| | Arial — safe fallback/system option | `'Helvetica Neue', Arial, sans-serif` |
Two properties fall out of this list and are worth keeping:
- **Arial is the zero-cost option** — its stack is byte-identical to today's
`--sans`, so it needs no webfont at all and doubles as the current default.
- **Twelve slots, eight web families.** Playfair Display, EB Garamond and IM Fell
English each serve two roles.
### 5.2 Loading
One combined request, not eight — Google Fonts accepts multiple `family=`
parameters per URL, and the font *binaries* are only fetched when a family is
actually applied:
```html
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600;700&family=EB+Garamond:ital,wght@0,400;0,600;0,700;1,400&family=IM+Fell+English:ital@0;1&family=Inter:wght@400;600;700&family=Merriweather:ital,wght@0,400;0,700;1,400&family=Playfair+Display:ital,wght@0,400;0,600;0,700;1,400&family=Source+Sans+3:wght@400;600;700&family=Work+Sans:wght@400;600;700&display=swap" rel="stylesheet" />
```
Static, in `index.html`, alongside the existing `preconnect` hints — a Google
Fonts URL is **never** built from admin input at runtime.
**Weight coverage gotcha:** IM Fell English ships **400 and italic only — no
bold.** `.display` and `.h1` use `font-weight: 600`, and `.btn` / `.eyebrow` /
`.badge` use 600–700, so choosing it yields browser-synthesized faux-bold. That is
acceptable for the display role (it is the authentic look) but is a reason not to
present it as a recommended body face.
## 6. Storage
Five new keys. `theme_visual`, `brand_assets` and `nav_public` join `PUBLIC_KEYS`;
`nav_admin` and `nav_player` are served by the authenticated endpoint from §4.2.
All are JSON strings, absent by default.
### 6.1 `theme_visual`
```json
{ "preset": "runic-gateway", "custom": null }
```
or, when the admin picks Custom:
```json
{
"preset": "custom",
"custom": {
"colors": { "bg": "#0e1318", "bgDeep": "#0b0f14", "panelA": "#192231", "panelB": "#141a21",
"accent": "#7f99bd", "accentBright": "#cdd9e8", "ink": "#eef3f8", "text": "#c4cdd8" },
"structure": { "radiusPill": "999px", "radiusPanel": "12px", "radiusCard": "10px",
"radiusInput": "8px", "shadowDepth": "0 14px 34px rgba(0,0,0,0.3)" },
"fonts": { "serif": "'EB Garamond', Georgia, serif",
"display": "Cinzel, Georgia, serif",
"sans": "Inter, Arial, sans-serif" }
}
}
```
`colors` / `structure` / `fonts` are independently overridable groups — a custom
accent without touching radius or fonts is expected. A group or field the admin
never touched falls back to whatever preset or `:root` value is active. **Never
null a field out to "clear" it** — remove it from the object.
### 6.2 Preset blocks
> **Superseded in Phase 3.** The presets below are correct as *values* and were
> built as specified, but they do **not** live in `theme.css` as `[data-theme]`
> blocks. They live in `server/src/config/themePresets.js`, and the server
> resolves the effective token set into `getPublic().theme` for the client to
> write onto `<html>`. See "Phases 3–4 as landed" for why, and note two
> corrections the build made to the palettes: each preset carries the **full**
> color set (fifteen tokens, not the eight below), and `--shadow-card` is
> themed alongside the radii.
`:root` stays the **Runic Gateway** default — today's actual values — so an
instance with no `theme_visual` row renders exactly as it does now.
`runic-gateway` is *also* declared as a named preset so that switching back to
it after trying another is the same code path.
```css
[data-theme="runic-gateway"] {
--bg: #0e1318; --bg-deep: #0b0f14; --panel-a: #192231; --panel-b: #141a21;
--accent: #7f99bd; --accent-bright: #cdd9e8; --ink: #eef3f8; --text: #c4cdd8;
--radius-pill: 999px; --radius-panel: 12px; --radius-card: 10px; --radius-input: 8px;
--serif: Georgia, "Times New Roman", serif;
--display: Cinzel, Georgia, serif;
--sans: "Helvetica Neue", Arial, sans-serif;
}
/* Modern — flatter, cooler, sans-heavy. Reads as a SaaS dashboard, not fantasy. */
[data-theme="modern"] {
--bg: #101114; --bg-deep: #0a0a0c; --panel-a: #1c1d22; --panel-b: #17181c;
--accent: #4f8ef7; --accent-bright: #a8c8ff; --ink: #f2f3f5; --text: #b8bcc4;
--radius-pill: 8px; --radius-panel: 8px; --radius-card: 6px; --radius-input: 6px;
--serif: Inter, Arial, sans-serif;
--display: 'Work Sans', Arial, sans-serif;
--sans: Inter, Arial, sans-serif;
}
/* Fantasy — warmer, higher contrast, carved corners; leans into UO harder. */
[data-theme="fantasy"] {
--bg: #1a120b; --bg-deep: #120c07; --panel-a: #2c1f14; --panel-b: #241a10;
--accent: #c9973f; --accent-bright: #e8c374; --ink: #f3e8d4; --text: #d3bfa0;
--radius-pill: 4px; --radius-panel: 3px; --radius-card: 2px; --radius-input: 2px;
--serif: 'EB Garamond', Georgia, serif;
--display: Cinzel, Georgia, serif;
--sans: 'EB Garamond', Georgia, serif;
}
```
**Derived-token rule (do not break this):** `--panel-grad` and `--shadow-card` must
stay expressed *in terms of* the other variables, never written as a literal
gradient in a preset block. If `--panel-grad` is ever hardcoded, a future light
preset silently inherits a dark gradient and looks broken. Likewise `--mode-live`
and `--mode-maint` (the status dots) are **semantic** — green means live — and stay
fixed across all presets rather than being themed.
### 6.3 `brand_assets`
```json
{ "logo": null, "hero": null, "favicon": null }
```
Each field, once set, holds the stored upload URL (`/uploads/1234-abcd.png`) — the
same shape `POST /admin/uploads` already returns. A `null` or absent field falls
back to `brand.logo` / `brand.hero` / `brand.favicon`; uploading a logo does not
force the admin to also pick a hero.
### 6.4 `nav_public` / `nav_admin` / `nav_player`
Keyed by the item's existing `to`:
```json
{
"/admin/posts": { "label": "Blog Posts", "order": 10 },
"/admin/settings": { "hidden": true },
"/admin/moderation": { "order": 5, "group": "Content" }
}
```
Any field absent for a given `to` falls back to the code default — label from
`NAV`, natural array order, `hidden: false`, original group. **Unknown `to` values
(not present in the current code's base array) are ignored, not stored and later
honored**, so removing a route in code can never leave a dangling override that
does something unexpected.
**`nav_public` may also be a wrapper** (Phase 10), because the public header is
the one nav an admin can restructure rather than only reorder:
```json
{
"items": { "/site/champs": { "order": 0, "section": "sec_a1b2" } },
"sections": [ { "id": "sec_a1b2", "label": "The World", "order": 4 } ],
"links": [ { "id": "lnk_c3d4", "label": "Player Guide",
"to": "/wiki/new-player-guide", "order": 1, "section": "sec_a1b2" } ]
}
```
- **A bare map is still read as the items map.** Every item key is a path
starting with `/`, so it can never collide with the literal key `items` — the
detection is unambiguous, and a nav with no sections still *stores* the bare
map, so this feature changed nothing for one that does not use it.
- `nav_admin` / `nav_player` keep the bare map; `sections` and `links` are
dropped for them, since neither layout can render an admin-created section.
- Top-level order is one number line shared by ungrouped entries **and
sections**; within a section, by its members. An admin-created entity with no
stored order appends after the coded ones rather than jumping to the front.
- **One level only.** No menu inside a menu.
- An `items[].section` or `links[].section` naming no declared section falls back
to the top level, mirroring the "group must name an existing title" rule.
## 7. Navigation: hard constraint
> **Amended in Phase 10.** This section originally said the override layer
> "cannot introduce a `to` that is not already in the corresponding hardcoded
> `NAV` array". That is still true of every **coded** entry, but the public
> header now also lets an admin add links of their own, so the constraint is
> restated below in the narrower form that survives. Nothing about the *gates*
> changed.
The override system can affect a **coded** entry's `label`, `order`, `hidden`,
and which container it sits in — `group` on the admin nav (an *existing* titled
section) or `section` on the public header (an admin-created dropdown).
It **cannot**:
- change a coded entry's `to`, or introduce a new one in its place;
- change or remove an entry's `roles` (admin nav) or `feature` (public nav) gate;
- un-hide an entry for a viewer whose role or feature check would otherwise fail.
**The public header may additionally carry admin-created `sections` and
admin-authored `links`** (§6.4, §7.2). This is a genuine widening and is worth
stating plainly:
- A **section** is a container with a label and a position. It has no `to` and is
never itself a link — it only opens — so it adds no reachable surface at all.
- A **link** is the one thing an admin may add to a nav, and the only place a path
is not required to already exist in code. It is restricted to a **same-origin
path**: no scheme, no protocol-relative `//host`, no whitespace or quotes. The
nav is not a place to send visitors to an origin the operator does not control.
- A link carries **no `roles` or `feature` of its own, and needs none**: the page
behind it enforces its own access, so a link to somewhere the viewer cannot
reach behaves exactly as typing that address would. Adding a link advertises a
route; it never grants one.
The property this rests on is structural rather than a check someone has to
remember: coded entries live in an `items` map whose keys **must** be routes the
base array declares, so that map can never introduce a route, while everything
that *can* name an arbitrary path lives in `links`, where the path rule is
applied on both the write and the read path.
The existing filters in
[`SiteHeader.jsx`](../../website/client/src/components/SiteHeader.jsx) and
[`AdminLayout.jsx`](../../website/client/src/routes/admin/AdminLayout.jsx)
run **after** the override merge, unchanged, and remain the actual security
boundary. The override layer is presentation-only. This is the same
"server-enforced gate, client-side is only about not advertising a dead end"
principle already documented in `SiteHeader.jsx`'s comments, and this feature must
not weaken it.
Three existing behaviors the merge must not disturb:
- **Empty dropdowns.** A section whose every entry is filtered out by a shard
feature must not render at all — a menu that opens onto nothing is worse than
no menu. `pruneNav` applies the gate inside a section and then drops one it
leaves empty.
- **Moderator confinement.** `AdminLayout` restricts moderators to `MOD_PATHS` and
redirects them out of anything else. Overrides apply before that filter, so a
moderator can still end up with a legitimately short sidebar — but the redirect
effect must keep working untouched.
- **Empty groups.** `AdminLayout` drops groups whose items all filtered out. An
override that hides every item in a group must produce no orphaned header.
### 7.1 Merge util
New shared pure module, `client/src/lib/navOverrides.js`:
```js
function applyNavOverrides(baseNav, overrides) {
// baseNav: the existing hardcoded array / grouped array — remains the source
// of truth for `to`, `roles`, `feature`, `icon`, `end`
// overrides: the parsed settings JSON, or null when the admin never touched it
// returns: a new array of the same shape with label/order/hidden/group applied
}
```
`overrides` absent → return `baseNav` unchanged. This is the "respect defaults"
path and is the single most important case to test.
**As built (Phase 1).** Two shapes are handled by the one function — flat
(`SiteHeader`, `PlayerPortalLayout`) and grouped (`AdminLayout`) — detected by
whether every entry carries an `items` array. Three rules the doc left open,
settled by the implementation and locked by tests:
- **Ordering.** An item the admin never reordered keeps its index in the base
array as its sort key, so setting one `order` does not scramble the rest.
Explicit and implicit keys therefore share one number line and can collide;
ties break **explicit first** (an admin who said "0" means first, not
"wherever the untouched item at index 0 already sits"), and two explicit
equal orders keep code order via a stable sort. The editor writes an order for
every item in a list the way drag-and-drop does, so ties are the stale-row
case, not the normal one — they just have to resolve predictably.
- **`group`.** Accepted only when it names a title the base nav already
declares; anything else is dropped, so an item can never land under a header
that does not exist. Group *order* is not overridable — sections stay in code
order, only membership and within-group order move.
- **Field-by-field validation.** A bad `label` does not discard a good `order`
beside it, and `hidden` is honored only as the literal boolean `true`.
Everything unrecognized is ignored rather than rejected, so a hand-edited row
degrades to the code default instead of rendering a broken nav.
`hidden: false` cannot un-hide anything: hiding here is subtractive only, and
the role/feature filters still run afterward, unchanged.
## 8. Build phases
Each phase is independently shippable and leaves the site rendering identically to
today until the admin acts.
| Phase | Work |
|---|---|
| **0 — Settings-store groundwork** ✅ | `settingsDb.remove()`; `DELETE /admin/settings/:key` with key allowlist; `GET /settings/nav` (§4.2); `parseJsonSetting()` helper; register the five keys; three into `PUBLIC_KEYS`. Swagger + route-manifest regen |
| **1 — `navOverrides.js` + tests** ✅ | The pure merge util, unit-tested in isolation. **The one piece with real correctness risk** |
| **2 — Radius/shadow token groundwork** ✅ | Promote the literals in `theme.css` to the four tokens of §4.7, values unchanged. Verify zero visual diff before any admin UI exists |
| **3 — Theme engine** ✅ | Three presets, the combined Google Fonts link, `SiteContext` extension, and the effective-value resolution in `getPublic().brand` (§4.5) |
| **4 — Admin theme UI** ✅ | `/admin/appearance` view + route in `App.jsx` + `NAV`/`TITLES` entries in `AdminLayout.jsx` |
| **5 — Brand assets** ✅ | Cached-shell rewrite in `app.js` (§4.3); upload endpoint on the existing multer config; `<img>` logo slot beside `MoonDot` in the shells; `heroImage` chain extension |
| **6 — Public nav wiring** ✅ | `SiteHeader.jsx` → `nav_public`. Lowest risk of the three: no roles, no groups |
| **7 — Nav builder UI** ✅ | `NavEditor.jsx` with `@dnd-kit` (new dependency), **Public tab only** |
| **8 — Admin + Player nav** ✅ | Wire the remaining two layouts, add the remaining two tabs, once the public pattern is validated in use |
| **9 — Palette-following literals + Parchment** ❌ **cancelled** | Was: promote the hue-carrying `rgba()` literals of §4.8 so they follow the palette, then the light-mode port. Not scheduled — see "Phase 9, cancelled" below |
| **10 — Public nav sections + added links** ✅ | Admin-created dropdown sections in the public header, coded entries organised into them, and admin-authored same-origin links. `NavDropdown.jsx`, `buildPublicNav`/`pruneNav`, the `nav_public` wrapper of §6.4, and the Public tab's own tree editor. **Amends §7** |
Phases 0–2 are one PR pair (website + docs), 3–4 a second, 5 a third, 6–8 a
fourth. **All four PR pairs target `edge`, not `main`** — the feature reaches
`main` as one `edge` → `main` merge once every phase is in, so no release ever
carries a half-wired theme engine. Phase 8 is the last one, so that merge is
what closes the feature.
### Phases 0–2 as landed
- **`/api/v1/settings` is a fifth router group**, not a route bolted onto an
existing one. §4.2 named the URL but not where it lives, and the domain split
leaves no group it fits: `/public` is anonymous, `/admin/settings` is
`adminOnly` while `AdminLayout` renders for editors and moderators, and
`/player` is data scoped to `req.user.id`. The group carries
`noindex, requireAuth` and no role gate. The route-manifest guard test that
asserts every `/admin/**` and `/player/**` route sits behind `requireAuth` now
covers `/settings/**` too.
- **Reset is `DELETE /api/v1/admin/settings/:key`** with the allowlist in
`settings.model.js` (`DELETABLE_KEYS`), which is what stops a stray request
from dropping `site_mode` or the uo-link config. It is admin-only and
idempotent, and a test asserts it never writes a row.
- **`parseJsonSetting` lives at `server/src/utils/settingsJson.js`.** Non-object
JSON (`4`, `"x"`, `null`, `[]`) is treated as absent alongside syntax errors,
and a validator rejection discards the whole object rather than half-applying
it. The client keeps `parseLayout`; a client-side counterpart arrives with its
first consumer in Phase 3.
- **Phase 2 was a 23-declaration promotion** — 14×`8px` → `--radius-input`,
4×`999px` → `--radius-pill`, 4×`10px` → `--radius-card`, 1×`12px` →
`--radius-panel` — matching the §4.7 census exactly. The `7px`/`6px` editor
chrome and the two `50%` circles stay literal. `--shadow-card` and
`--panel-grad` were **already** tokens and already derived, so the shadow half
of the phase was a no-op; the only two `box-shadow` declarations in
`theme.css` both already read `var(--shadow-card)`.
### Phases 3–4 as landed
Four things the design settled differently once it met the code.
**1. The server resolves the whole token set; there are no `[data-theme]`
blocks.** §6.2 put the presets in `theme.css` and had the client set a
`data-theme` attribute. That does not work as written: `SiteContext` writes
`--accent` as an **inline style on `<html>`** (`SiteContext.jsx:31`), and an
inline property beats any attribute-selector block. An admin who picked Fantasy
without also setting a custom accent would have had Fantasy's `#c9973f` painted
over by `brand.accent` from env — and §4.5's whole point is that
`getPublic().brand.accent` is what the phone app themes itself from, so the two
surfaces would have disagreed about the accent while both being "right".
The fix removes the conflict rather than sequencing around it. Presets live in
`server/src/config/themePresets.js`; `server/src/utils/themeResolve.js` layers
`:root` ← preset ← custom **per field** into a token map; `getPublic()` returns
it as `theme`; `client/src/lib/themeVars.js` writes it onto `<html>`. One
authority for the merge, `brand.accent` is by construction the accent the site
actually paints, and `theme.css`'s `:root` is untouched — an instance with no
row gets no `theme` block, the client writes nothing, and the page renders
byte-for-byte as today.
The client half's real logic is *removal*: inline properties are not cleared by
writing a smaller object over them, so `applyThemeTokens` tracks what it set
last time and `removeProperty`s whatever the new payload no longer mentions.
Without that, "Reset to defaults" would look broken until a reload.
**2. Presets carry the full fifteen-token palette, and theme `--shadow-card`.**
§6.2's blocks set eight colors. Applied literally, Fantasy's warm brown page
would have kept `--line: #2a3544` and `--blue: #13243c` — dark blue-grey borders
and a blue-grey active nav row — because those tokens are not in the list.
Every preset now sets `--panel-flat`, `--line`, `--line-soft`, `--head`,
`--muted`, `--dim` and `--blue` as well. The admin *form* still exposes only
§6.1's eight; the rest are supporting shades a preset gets right coherently but
that are not worth hand-picking. `--mode-live` / `--mode-maint` stay fixed
across every preset (green means live) and `--panel-grad` stays derived, both
locked by tests.
**3. The option catalog is served, not duplicated.**
`GET /api/v1/settings/theme/options` returns the presets (with their full token
maps, so a control can show what an unset field currently resolves to), the font
shortlist, the shadow depths, and the editable field names paired with the CSS
variable each drives. Duplicating those lists in client code would mean the form
could offer a font the server rejects, which surfaces as a save 400ing for no
visible reason. A test asserts every offered option validates.
Validation is deliberately asymmetric: **strict on write** (`PUT
/admin/settings` 400s and names the offending field) and **forgiving on read**
(a bad field is dropped, its neighbours keep applying). Strict-on-write gives
feedback; forgiving-on-read means a row hand-edited in the DB degrades to the
shipped default instead of rendering a broken site.
One addition to §5.1's twelve font options: **Georgia in the serif list.** The
shortlist gave the sans role a "today's default" option (Arial, byte-identical
to `--sans`) but left serif with no way back to `Georgia, "Times New Roman",
serif` short of resetting the whole theme. It pulls in no web family, so §5.2's
combined URL is unchanged.
**4. The Discord bot fetches the accent; §4.5's `accentInt` note was a no-op.**
See the correction in §4.5. `bot/src/brand.js` now reads
`GET /public/settings` → `brand.accent` through the public-API client it already
had, behind getters with a 10-minute TTL — so `brand.accentInt` stays a plain
property read at every existing call site, an embed never awaits a network call,
and any failure (site down, maintenance, malformed body) keeps the last known
good value with `BRAND_ACCENT_COLOR` as the floor.
**Found while smoke-testing: §4.8's rgba literals are not only a light-mode
problem.** The 28 dark-assuming `rgba()` literals were scoped to Phase 9 on the
reasoning that they break a *light* preset. Applying **Fantasy** on a live
instance shows they also carry a **hue**: `.btn-ghost`'s
`background: rgba(11, 22, 48, 0.45)` (essentially `--blue` at 45%) leaves the
portal's quick-link buttons reading blue on a warm brown page, and the hero
overlay stack in `heroLayout.js` is `rgba(11,15,20,…)` regardless of preset.
Nothing is broken or unreadable — it is a visible seam, not a bug — but Phase 9
should be re-scoped from "light-mode port" to "make the hue-carrying literals
follow the palette", which the dark presets need too. Not fixed here: it is the
23-declaration-style promotion Phase 2 was, and folding it into the phase that
introduced the presets would have hidden it inside an unrelated diff.
**Deferred to Phase 5, and done there:** the theme arrives with the
`/public/settings` fetch, so a themed instance painted the shipped palette for
one frame before repainting. Phase 5 had to rewrite `renderIndexHtml` into a
cached, invalidated shell anyway (§4.3), and injecting a `<style>` block with the
effective tokens there removed the flash for free rather than solving it twice.
**Also fixed in passing:** `settings/nav.controller.js` imported the logger
*factory* rather than calling it, so `log.error` was `undefined` and a DB fault
would have thrown a `TypeError` inside the catch — no response sent, request
left hanging — instead of returning a 500. Introduced in Phase 0.
### Phase 5 as landed
**The upload is one call, not two.** §8 said "upload endpoint on the existing
multer config", which reads as: reuse `POST /admin/uploads`, then `PUT` the
`brand_assets` row. Two problems with that. The generic upload is `staffOnly` —
editors can reach it — while the row it would write is `adminOnly`, and the
site's identity is not the editor tier's to change. And a run that uploaded and
then failed (or was abandoned) would leave a file in `/uploads` that nothing
references.
So: **`POST /api/v1/admin/settings/brand-asset/:slot`**, `adminOnly`, using the
shared `imageUpload.js` multer config and returning `{ url, brand_assets }`. It
read-modify-writes the row, so uploading a logo never clears a hero (§6.3). The
per-slot rules only ever *tighten* the shared allowlist, never widen it (§9):
| Slot | Types | Cap |
|---|---|---|
| `logo` | the shared image allowlist | 1 MB |
| `hero` | the shared image allowlist | 8 MB (the shared ceiling) |
| `favicon` | **PNG only** (§4.10) | 512 KB |
The cap is enforced after multer has written the file and the file is unlinked
before the response, rather than by a second multer instance with its own limits.
One upload config and one allowlist is the property worth keeping; a briefly
written file that is deleted before the request returns is not.
**There is no per-slot delete route.** Clearing one asset is a `PUT` of the
remaining ones, and clearing the last one is the existing reset-by-delete —
`{}` is never stored, because absence of the row is what selects the env
defaults (§2) and a stored empty object would be a second way to say the same
thing.
**`brand_assets` needed a validator of its own, which the design did not
anticipate.** These are the only settings values written straight into HTML as
URLs the browser then fetches — an `<img src>`, a `<link rel="icon">`, an
`og:image`. `utils/brandAssets.js` accepts a same-origin path under `/uploads/`,
`/brand/` or `/assets/` and nothing else: no scheme, no protocol-relative
`//host` (which looks like a path and loads off-origin), no `..`, no whitespace
or quotes. Same asymmetry as the theme — strict on write with the field named,
forgiving on read so one hand-edited slot does not cost the admin the other two.
**The shell cache carries a TTL as well as explicit invalidation.** §4.3 asked
for a module-level cache invalidated on write, and that is what the settings
controller does. But the cache is *per process*: in a scaled deployment the
worker that handled the write is the only one that learns of it, and every other
would serve the old favicon until the next restart. A 5-minute TTL makes the rest
converge on their own while keeping the steady state at one render per process
per five minutes — not one per page view. Concurrent first requests share a
single render, an invalidation that lands mid-render is not overwritten by the
in-flight result, and a failed settings read renders the env-only shell and
caches *that*, so an outage is not a failing query per page view.
**Theme flash: fixed here, with a handoff.** The shell now also carries the
resolved tokens as `<style id="theme-boot">:root{…}</style>`, injected last in
`<head>` so it follows the built stylesheet and wins the equal-specificity tie.
`SiteContext` removes that block once the `/public/settings` payload has arrived
and been applied — otherwise a later reset would remove the inline properties
only to reveal the stale block underneath. The removal is gated on a
**successful** fetch, not merely a finished one: a failed request leaves the app
with no theme at all, and dropping the block then would strip a themed instance
back to the shipped palette for no reason.
**The logo went into all six MoonDot surfaces, not three.** §8 named the three
persistent shells (site header, admin sidebar, portal sidebar); the admin login,
the player login/register card and the maintenance page carry the same mark and
an operator who uploads a logo means their instance, not three of its pages.
`components/BrandLogo.jsx` renders **nothing** when `brand.logo` is empty — which
is the shipped default — so every one of those surfaces is unchanged on an
untouched instance. On the three centered layouts the logo is stacked *above* the
moon rather than beside it, because turning that block into a flex row would have
changed its height on instances with no logo.
The footer's "powered by Runic Gateway" emblem is deliberately untouched (§4.11):
it is the project's badge, not the instance's.
**The hero chain needed no code.** §4.9's real order —
`hero_layout.background.image_url` → `brand_assets.hero` → `BRAND_HERO` →
`/assets/img/runic-emblem.png` — already holds, because Phase 3 resolved
`brand_assets` into `getPublic().brand.hero` and `SiteContext.heroImage` reads
that. What was missing was saying so: the hero row in the admin panel now states
that a hero-editor background wins over the uploaded one, so "I uploaded a hero
and the portal ignored it" does not become a bug report against a working system.
**Observed and left alone:** the shell's `<title>` and description still come
from `BRAND_NAME`/`BRAND_DESCRIPTION`, not from the admin-set `site_title` that
`getPublic().brand.name` prefers, so an instance that renamed itself through the
admin panel still has the env name in its tab and its link previews. Fixing it
would change the served shell for instances with no `brand_assets` row, which is
exactly what §9 says must not change in this phase. It wants its own change.
### Phases 6–8 as landed
The nav half, wired end to end: the public header, the admin sidebar and the
player portal all read their override row, and `/admin/navigation` writes them.
Five things the design did not settle.
**1. The server had no way to store a nav row, and would have stored garbage.**
§8 described phases 6–8 as client work, and for the *merge* that is right. But
`updateSettings` validates and stringifies `theme_visual` and `brand_assets` and
lets everything else through to `settingsDb.set` — so a `nav_public` object would
have been written as the string `"[object Object]"`, which `parseJsonSetting`
then reads as absent. The save would have returned 200 and done nothing, for
ever. `server/src/utils/navOverrides.js` mirrors `utils/brandAssets.js`:
`validateNavOverrides` is strict on write and names the offending key,
`resolveNavOverrides` is forgiving and drops fields that would do nothing.
**2. The server cannot check that a `to` exists, and should not try.** The three
base `NAV` arrays are client constants. Shipping a copy to the server would
create a second source of truth for navigation that drifts the first time a route
is added, and it would buy nothing: `applyNavOverrides` already drops an entry
whose `to` the base array does not declare, which is the right place for it — a
route deleted in code stops mattering immediately, with no migration. **The
server validates shape; the client owns membership.** So the write path accepts
any app-internal path as a key (absolute, no scheme, no `//host`, no whitespace)
and rejects everything else, and it rejects any field that is not one of the
four — a `roles` or `to` in the body is a 400, not something quietly stored.
**3. `hidden: false` is accepted and never stored.** The editor sends it while a
row is being edited, so rejecting it would be hostile; storing it would leave a
row that reads like an instruction to *force* something visible, which this layer
must never be able to express. It is dropped on the way in, and hiding stays
subtractive.
**4. The nav editor cannot be hidden, and that is enforced three times.** An
admin who hid `/admin/navigation` would lose the only screen that can un-hide it.
The row's eye toggle is disabled with a note saying why; `resolveNavOverrides`
drops `hidden` on that one `to` for `nav_admin`; and `AdminLayout` strips it
again before merging, which is what also covers a row edited straight in the
database. Typing the URL still works regardless — the guard is about not
stranding an admin who never learned it.
**5. Orders are written only when something actually moved.** §7.1 says the
editor writes an order for every item "the way drag-and-drop does", and it does —
but only for a nav whose sequence differs from the code's. An admin who renames
one item stores exactly one field, and a route added to `NAV` later still lands
where the code puts it. The comparison is against the base **restricted to the
rows that admin can see**, so a role- or feature-gated item missing from their
palette is not mistaken for a reorder. An override for such an item is carried
through their save untouched rather than quietly reset.
Two smaller notes. The section dropdown offers "(no section)" only to rows coded
into an untitled group (Dashboard, Account): for anything else it is a move an
override cannot express (§6.4 allows an existing titled section or nothing), so
offering it would silently do nothing. And `useNavOverrides` keeps one
module-level copy of the two authenticated rows, which is what lets a save in the
editor update the sidebar the admin is looking at without a reload — and stops
the second layout to mount from flashing the coded nav first.
### Phase 10 as landed
Asked for after phases 6–8 were built and before the `edge` → `main` cutover:
the public site should support dropdown sections with links organised inside
them. Scoped to the **public header only** — the admin sidebar keeps its four
coded sections and the player portal its three flat rows — and to **same-origin
links**, which is what makes §7's amendment a narrowing rather than an opening.
**The shape change was free because nothing had shipped.** `nav_public` grew a
`{items, sections, links}` wrapper. Had this landed after the cutover it would
have needed a migration or a version field; before it, a forgiving read of the
bare map is enough, and that read is kept anyway as insurance for a row written
during review.
**Sections are entries in the top-level order, which is why the Public tab has
its own editor.** The admin sidebar's groups are a fixed frame the code declares:
only membership moves. A public section is something the admin created and can
drag among the pills. That is a tree, not a list of groups, so
`PublicNavTree.jsx` renders it with a nested `SortableContext` per section, while
the other two tabs keep the phase-7 grouped editor. The shared `Row` was
generalised — its destination `<select>` takes a list of choices instead of
knowing about admin group titles.
**Moving between containers is still the dropdown, not a drag**, exactly as on
the Admin tab. Cross-container dragging is a lot of interaction surface for
something an admin does once, and keeping every drag a simple reorder is what
lets the nested contexts stay independent.
**Deleting a section does not delete what is inside it.** The entries move back
to the top level. It is the one destructive act this screen could commit — those
are coded pages and the admin's own links — so it is locked by a test.
**The dropdown opens on click, never hover, and the trigger is not a link.** A
hover menu is unusable on touch, and making the trigger navigate means tapping to
open takes you somewhere instead. A section is a container, not a destination.
`NavDropdown.jsx` carries the rest of the contract: Escape closes and returns
focus, an outside press closes, navigating closes, Arrow Up/Down walk the items,
and `aria-haspopup`/`aria-expanded` let it be announced as a menu.
**A bug the palette filter had, found by the test for it:** `buildNavOverrides`
judged "does this route still exist?" against the *palette* — the base array
already filtered to what the editing admin can see. For the admin nav that is
harmless (an admin sees every row), but on the public header a shard-feature-gated
row is filtered out, so the guard meant to carry its override through could never
fire, and their save would have quietly reset it. Membership is now judged against
the **full** coded nav while the rows still come from the palette: they are two
different questions.
### Phase 9, cancelled
The §4.8 `rgba()` literal promotion and the Parchment light-mode port are **not
scheduled**. The finding that motivated them stands and is worth keeping: those
literals carry a *hue*, not merely a light/dark assumption — `.btn-ghost` is
`rgba(11,22,48,0.45)`, so the portal quick-links read blue on Fantasy's warm
page. It is a real rough edge in the three dark presets, not only a blocker for a
hypothetical light one. It is simply not worth the contrast pass across every
component right now. Anyone picking it up should start from the census in §4.8
and the live observation in "Phases 3–4 as landed".
### 8.1 Admin builder UI notes
- Tabbed control for the three navs; drag-and-drop reorderable list.
- **The palette is filtered to the editing admin's own visible items** — the base
array run through *their* role/feature check — so an admin cannot drag in, and
therefore can never accidentally expose, an item they cannot already see
themselves. A deliberate UX guardrail on top of the merge-time enforcement.
- Per item: label input with a "reset to default" that clears the override, an eye
toggle for `hidden`, and on the Admin tab a group dropdown limited to the fixed
set of titles already in `NAV`.
- "Reset to defaults" per nav **deletes the row** (§4.1), never saves `{}`.
## 9. Acceptance criteria
- Fresh instance, no admin action: colors, fonts, radii, brand assets and all
three navs render byte-for-byte as today, driven by `BRAND_*` and the current
hardcoded `theme.css` / `NAV` arrays.
- After Phase 2 and before any admin UI exists, the rendered site is visually
identical — the token promotion is a true no-op.
- Setting `theme_visual.custom.colors` alone changes colors only; radius, fonts,
assets and nav are unaffected.
- Font dropdowns only ever produce values from the §5.1 shortlist. No admin input
is concatenated into a `font-family` string or a Google Fonts URL at runtime.
- Setting only `brand_assets.favicon` changes the served favicon only — the OG
image and hero backgrounds still resolve from `brand.js` env values.
- With no `brand_assets` row, the served HTML shell is **byte-identical** to
today's. Covered by a server-side test in `publicBrand.test.js`.
- Uploaded assets go through the existing `imageUpload.js` mimetype allowlist. No
second upload path with weaker validation.
- `getPublic().brand` with no new rows returns exactly what it returns today —
the existing `publicBrand.test.js` assertions pass verbatim.
- An admin cannot, through the nav builder, cause any user to see a nav item their
role/feature gate would otherwise hide. Verified by overriding `hidden: false`
on a role-gated item as a lower-privileged test admin and confirming the filter
still hides it.
- A dropdown section whose every entry is hidden by shard visibility **does not
render at all**, rather than opening onto an empty menu.
- An added link cannot leave the origin: a `to` carrying a scheme, a
protocol-relative `//host`, whitespace or quotes is refused on write and dropped
on read. An added link never grants access — the page behind it still gates
itself.
- Deleting a dropdown section returns its entries to the top level; it never
removes a coded page or an admin's own link.
- Deleting a theme/asset/nav row returns that surface to env/code defaults, not to
a stored copy of the defaults.

View File

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

View File

@@ -482,7 +482,7 @@ Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`.
| `CLIENT_ORIGIN` | `http://localhost:5173` | enables CORS in dev only |
| `LOG_LEVEL` / `FILE_LOG_LEVEL` | `info` / `debug` | console / file verbosity |
| `LOG_TO_FILE` / `LOG_DIR` / `LOG_FILE` | `true` / `<server>/logs` / `app.log` | log file (bind-mounted to `./logs` in Docker) |
| `ANNOUNCE_POLL_MS` | `15000` | how often the news-announcement dispatcher sweeps `announce_jobs` for due/retry legs (town crier + Discord) |
| `ANNOUNCE_POLL_MS` | `15000` | how often the news-announcement dispatcher sweeps `announce_job_legs` for due/retry legs (whichever are registered — Discord is core's, the town crier is module-uo's) |
| `TOWNCRIER_DURATION_SEC` | `3600` | how long a news post's in-game town-crier message stays up (≤ `86400`) |
---