107 Commits

Author SHA1 Message Date
732927a6bb fix(modules): three defects a real install exposed (phase 4, slice 1)
Standing the slice-2 screen up against a live server and installing the
published module-uo v0.3.0 through it found three things, none of which any
unit test in this repo could have caught. Two of them are older than this
phase.

1. The boot refresh nulled every install's provenance
--------------------------------------------------------
`installed_modules.source` and `.sha256` exist so the admin panel can say
where a module came from. They never survived a restart.

`lifecycle.boot()` re-records every scanned module with no source and no
sha256 -- correctly, because a scan finds a directory and never where it came
from -- and `upsert` assigned both columns unconditionally. So an install's
provenance lasted exactly until the restart that install asked for, and the
screen then described a module installed from a URL as "placed on the volume
by hand". Verified live: install, restart, provenance gone.

Nothing could have caught it before now. Phase 4 wrote the first non-null
value these columns had ever had, so lifecycle.js's comment asserting that
"recordInstalled leaves what it is not given" described an intention rather
than the statement below it -- and modules.model.test.js's fake reproduced
the defect faithfully, assigning unconditionally just like the SQL.

Fixed with COALESCE(VALUES(col), col): a value overwrites, a NULL leaves what
is there. The fake now matches, and two tests pin both directions -- a boot
refresh must not wipe it, and a re-install from a new URL must still replace
it, or the column would become write-once and an upgrade would for ever show
where the first version came from.

2. The restart killed the server on Windows instead of stopping it
------------------------------------------------------------------
The route called `process.kill(process.pid, 'SIGTERM')` to reach server.js's
graceful-shutdown handler. That works on Linux. **Windows has no POSIX
signals, and Node documents SIGTERM there as unconditional termination of the
target process** -- so on a Windows host the restart killed the server
outright: no module onShutdown, no listener close, no pool close, no log
flush. Observed exactly that: the process was gone and the shutdown handler
had logged nothing at all.

`process.on('SIGTERM', ...)` is an ordinary EventEmitter listener, so
`process.emit('SIGTERM')` reaches the same handler on every platform without
involving the OS. One shutdown path, still; it just gets there by an event.

Deployment is Linux containers and would never have shown this. Development
is not, and neither is the smoke that found it.

The test was worse than useless: it stubbed `process.kill` and asserted it
had been called with SIGTERM, which is precisely the call whose MEANING
differs by platform. It now waits for the SIGTERM EVENT -- what server.js is
actually subscribed to -- so a pass here means the handler would run.

3. `present()` did not publish the running version
--------------------------------------------------
An upgrade writes new files and a new row while the old code stays loaded, so
the row's version is a promise about the next boot rather than a description
of this one. Adds `liveVersion` from the loader beside `liveState`, so the
screen can tell the two apart instead of reporting the new version as running.

723 server tests (+2), manifest and OpenAPI both unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 03:48:27 -05:00
b30e82cde2 feat(modules): install, uninstall, purge and restart (phase 4, slice 1)
All checks were successful
PR Checks / bot-install (pull_request) Successful in 17s
PR Checks / client-build (pull_request) Successful in 26s
PR Checks / server-tests (pull_request) Successful in 33s
The consumer half of a release module-uo's CI has been publishing since
phase 3 closed. Before this, core had the installed_modules provenance
columns and no code that could ever fill them: nothing fetched, verified,
unpacked, removed or purged anything, and there was no admin route at all.

Adds modules/archive.js, modules/install.js, schema.runPurge(),
lifecycle.stop(), loader.stopHook(), and /api/v1/admin/modules with eight
routes. 797 server tests (+76), manifest 158 -> 166 + 2 internal, OpenAPI
gains 8 operations and loses nothing.

Reject, never sanitise
----------------------
The download is the easy part: an https-only allowlist re-checked on every
redirect hop, a declared sha256 compared against the bytes that arrived, and
a byte cap. Unpacking is where the archive chooses the filenames, and core
writes into a directory bind-mounted from the host, so an escape is not
confined to the container.

archive.js inspects the whole archive before a byte is unpacked and refuses
absolute and drive-absolute paths, `..` segments, NUL bytes, backslashes,
anything that is not a regular file or a directory, more than one top-level
entry, and anything over the entry or byte caps. Refusing symlinks and
hardlinks outright is what keeps this off the majority of node-tar's
published advisories rather than depending on the library to contain them.

That two-pass shape is load-bearing, and it was measured rather than assumed:
extracting an archive whose fourth member escapes upward throws under
node-tar 7.5.22 -- and leaves the first three members on disk. The loader
scans that directory at require time on the next boot, so a half-unpacked
module is a module. Everything therefore happens in a scratch directory that
is removed on any failure, and the move into place is the last step.

`tar` is pinned to ^7.5.22 rather than the ^6 that installs by default: 6.x
is flagged critical, and reading the advisory list is what the file's header
now says out loud -- almost all of it is hardlink or symlink traversal and
PAX header interpretation differentials, which is exactly this feature's
threat model.

Two things the plan had wrong
-----------------------------
The bundle's top-level directory is `module-uo-<version>`, not the module id
-- so "the top-level name must equal the id" was checked against nothing real.
The extractor strips that level instead, because its name belongs to whoever
published the bundle and the directory it lands in is core's. What is checked
instead is the unpacked module.json: a manifest promising `uo` and delivering
something else is refused rather than installed under the name it promised.

And purge cannot be a follow-up action (decision 5): purge.sql lives inside
the directory uninstall deletes. It is offered in the uninstall flow and as a
standalone action on a still-installed module, and the standalone one refuses
unless the module is already disabled -- dropping tables under something that
is still serving leaves it answering out of a world that no longer exists.

Disable now means stopped
-------------------------
lifecycle.stop() dispatches that one module's onShutdown before flipping the
guard, so a module an operator switches off actually releases its sockets and
closes its streams instead of merely becoming unreachable. The hook runs
first and the state moves after it, because while onShutdown runs the module
is still `started` and that is the only state in which its routes and the
world it is tearing down agree. A hook that throws does not stop the disable
-- the opposite of the boot path's rule, and deliberately.

Enable is not its mirror and there is no start(id) beside it. There is no
onBoot re-dispatch and the hooks were never promised re-entrant, so enable
moves the row and the restart route starts it. A test pins that enable does
not touch the loader, because "fixing" it is a one-line change that would put
a module with closed sockets back on the nav.

Restart raises SIGTERM against its own process rather than calling the
shutdown path directly, so server.js's handler stays the one graceful-shutdown
path and this route cannot drift from it.

The allowlist bootstraps from MODULE_SOURCE_HOSTS into a settings row and is
admin-managed after that (decision 6); seedDefault is INSERT IGNORE, so
changing the variable on an existing deployment is a no-op by design. An empty
list forbids every install rather than allowing every host -- the safe
direction for a value someone might blank by accident.

Verified against the real v0.3.0 release
----------------------------------------
Not a fixture: fetched the published install manifest over the real Gitea
host and its redirect chain, verified the sha256, inspected and unpacked the
252,517-byte artifact to 82 files, and then booted core against the result --
the module registered its five mounts, seven streams and eight capabilities
and resolved its client chunk, with no scratch directory left behind.

Two defects this slice's own tooling caught, both of which had already been
written down as classes:
  - the controller destructured runPurge at require time, capturing the
    function rather than the module, which made the one dependency whose
    ORDER matters the one that could not be substituted;
  - two swagger annotations carried an apostrophe inside a quoted string,
    dropped silently by swagger-autogen before slice 5 taught it to fail loudly.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 03:09:45 -05:00
2cb549e9e5 Merge pull request 'feat(modules): merge module OpenAPI fragments into /api/docs.json (phase 3, slice 5)' (#141) from feature/module-openapi-merge into edge
Reviewed-on: #141
2026-08-12 04:12:07 +00:00
adff20be7b feat(modules): merge module OpenAPI fragments into /api/docs.json (phase 3, slice 5)
All checks were successful
PR Checks / bot-install (pull_request) Successful in 18s
PR Checks / client-build (pull_request) Successful in 26s
PR Checks / server-tests (pull_request) Successful in 31s
Core's half of the slice that closes phase 3. Two things: the request-time
fragment merge core has owed since phase 1, and the last of core's UO copy.

**The merge (MODULE_API.md §6.1a).** `swagger-output.json` is core's own routes
and cannot be anything else — it is generated on a developer's machine and
committed, so it must come out the same regardless of what they had checked out,
and a module arrives on a volume long after the image was built. Module routes
therefore reach the document at request time, from the `swagger-fragment.json`
each module ships: `swagger/docsSpec.js` merges the fragments of STARTED modules
over the committed spec, cached on a new loader state version and rebuilt when a
module's state moves.

Until now neither half existed. `swagger/mergeSpec.js` named the request-time
caller in its header and that caller was never written, so the 72 routes
module-uo serves were in no OpenAPI spec at all — core's standing rule ("never
ship a route that isn't in the spec") broken by the extraction rather than by a
route.

Core wins every key collision, `swagger-output.json` is never mutated (it is a
require()d JSON module — one in-place merge would be permanent AND cumulative),
and a fragment that is missing or unreadable costs that module its paths and
nothing else. The Swagger UI is now built per request for the same reason the
JSON is: bound once at require time it would show core's routes for the life of
the process while /api/docs.json showed the merged set.

**The last of core's UO copy** (slice 4 deferred it; §5.2's check reads code, not
prose, so none of this was caught):

- 31 UO schemas and 4 UO tags in `swagger/swagger.js`, describing routes core has
  not served since slice 1 — 578 lines. They moved to module-uo, namespaced
  `Uo…`, and arrive back through the merge on an instance that installs it.
- `info.description` said "a private Ultima Online shard".
- README.md's 48 UO mentions, including the architecture diagram and the whole
  `## Shard integration (uo-link)` section, now `## Modules`.
- `TOWNCRIER_DURATION_SEC` and `UOLINK_*` in the two `.env.example`s: read by the
  module, not by core, and documented in the module's README instead.

**Two dropped annotations, and the reason nobody knew.** swagger-autogen reports
an annotation it cannot parse and then prints Success in green, having skipped
it. `npm run swagger` now captures its diagnostics and fails — which immediately
found `POST /api/v1/admin/invites` and `POST /api/v1/auth/invite/:token/accept`
documented with an EMPTY request body, both since the day they were written.

Fixing the tag list also cleared five tags used by routes but never declared
(`Admin · Email`, `Admin · Invites`, `Admin · Moderation`, `Admin · Pages`,
`Auth · Me`) — the same defect class, in the other direction.

- 646 server tests (+9), 157 client tests unchanged
- routes.manifest.json unchanged (158 public + 2 internal); check:modules clean
- swagger-output.json: 128 paths, 69 schemas, 0 orphan tags, 0 orphan schemas
- verified against a real boot with module-uo installed: 197 merged paths
  (128 core + 69 module), all four module tags, 31 Uo schemas, no dangling $refs,
  /api/docs renders the module's operations with zero console errors

Refs: docs/website/MODULE_API.md §2.8, §6.1a; MODULE_SYSTEM.md §2.7.1

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 22:57:37 -05:00
87230c879a Merge pull request 'refactor(modules)!: de-UO core's copy, and enforce it (phase 3, slice 4)' (#140) from feature/module-de-uo-core into edge
Reviewed-on: #140
2026-08-12 03:02:37 +00:00
0c4eacfa4a refactor(modules)!: de-UO core's copy, and enforce it (phase 3, slice 4)
All checks were successful
PR Checks / bot-install (pull_request) Successful in 18s
PR Checks / client-build (pull_request) Successful in 27s
PR Checks / server-tests (pull_request) Successful in 31s
Phase 3's acceptance criterion 1, made real. Three things, one review:

**The dead bindings.** `client/src/api/client.js` still carried ~190 lines of UO
namespaces — `shard`, `atlas`, the two SSE URLs, `admin.shard/shardOps/atlas/
userShard`, the uo-link and town-crier calls, `player.shard` — with zero core
consumers since slice 3 deleted the views. module-uo vendors its own bindings.
The five assertions core's `apiClient.test.js` made about those URLs moved with
them (Module-uo#5); the encoding test that used `governorHistory` now uses a
core route.

**The copy.** Core is the platform, not one game's site, so its words are
game-neutral now: `About`, `Screenshots`, `Website`'s cards, `Status` (which was
never about a game server at all — it reports site mode), `Wiki`, `SiteFooter`,
the default hero, `brand.js`'s tagline and description, the seeded wiki
categories, and two user-visible NavEditor strings that named a module's admin
screen by its proper name. Which game an instance is for is the operator's to
say — BRAND_* vars, the hero editor, CMS pages — and every real instance already
does: `.env.uomysticmoon.example` sets both brand strings explicitly, so nothing
live changes wording. Wiki page SLUGS are untouched: `seedDefault*` only inserts
what is absent, so renaming one adds a duplicate page to every install.

Also gone: an orphan comment block in `schema.sql` describing the spawn-atlas
tables slice 1 took away, and the two settings rows core seeded for a module
(`game_account_signup`, `uo_link_protocol_3_migrated`). The second was a live
defect — see Module-uo#5, which takes ownership of both and repairs the
one-shot migration core's ordering had disabled.

**The check.** `scripts/checkModuleIdentifiers.js` + `npm run check:modules`,
first step of the server-tests job because it needs no dependencies. It reads
CODE, not prose — file names, import specifiers, route path literals, declared
identifiers and property names — per §5.2, so core's English may still say
"shard" where saying it is worth more than the word costs.

Two things it gets right only because getting them wrong was tried first: it
matches WHOLE WORDS (a substring pass flags `defaultImage`, which contains
"ultIma", four times in this repo), and it strips comments and string bodies in
one character walk (a comment contains quotes, a string contains `//`) — the
`checkImports.js` lesson. It has its own 17-test suite, because a boundary check
that silently stops checking is worse than none. The three §6.5 grandfathering
allowlists are exempt by name, and an exemption that stops matching fails the
build rather than lingering.

BREAKING CHANGE: core no longer seeds `game_account_signup` or
`uo_link_protocol_3_migrated`; module-uo's schema fragment does. An install
running core without module-uo keeps whatever rows it already has and gains no
new ones — nothing in core reads either key.

Deferred to slice 5, deliberately: README.md's 48 UO mentions, including a
`## Shard integration (uo-link)` section and the architecture diagram. That is
documentation, which §5.2 does not cover, and it belongs with the phase-closing
docs pass rather than half-done here.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 21:41:18 -05:00
a99ead4ee4 Merge pull request 'refactor(client): delete the UO client half (phase 3, slice 3)' (#139) from feature/module-extract-client into edge
Reviewed-on: #139
2026-08-12 00:35:51 +00:00
5bdb6a7e10 fix(modules): guard the portal's nav icon, resolve MODULES_DIR absolutely
All checks were successful
PR Checks / bot-install (pull_request) Successful in 18s
PR Checks / client-build (pull_request) Successful in 26s
PR Checks / server-tests (pull_request) Successful in 29s
Both found by the §7.7 browser smoke, running the slice-3 pair together, and
neither is visible to any test in either repo.

`PlayerPortalLayout` rendered `<n.icon />` unguarded while `AdminLayout` guarded
its equivalent. `icon` is optional in the nav contract, and every core row in
that sidebar has always had one — so the difference cost nothing until a module
registered a row without, and then it was not a missing glyph, it was React
error #130 and a blank player portal. Guarded now, like its neighbour.

`MODULES_DIR` is resolved absolute. `resolveClient` checks containment by
comparing an absolute `path.resolve(dir, entry)` against the module directory,
so a RELATIVE `MODULES_DIR` — which is what §7.7's own recipe produces when run
from `server/` — failed every module with "client.entry escapes the module
directory". A perfectly-placed entry, and a message pointing at the module.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 18:54:31 -05:00
f7d27f7a06 refactor(client): delete the UO client half (phase 3, slice 3)
35 files and 5,332 lines out — twelve public pages, seven admin views, two
player views, eight components, the two `data/` leaves and the three `lib/`
ones, plus the two tests that came with them. §2.7.1's estimate of 51 files /
~3,700 lines was measured differently and is corrected in the docs PR.

The seams core keeps, each smaller than what it replaced:

Nine rows leave the public header and six leave the admin sidebar, and both
lists are now free of `feature` gates and of `IconShard`. `moduleTitle` already
handled a module page's heading, so the six TITLES entries and the
`/admin/characters` branch of `sectionTitle` simply go.

`/player` had `PlayerCharacters` as its index — a UO page — and rather than name
a replacement or invent a landing screen it now resolves to the first row of the
portal nav this viewer can reach (`firstDestinationFor`, beside
`allowedPathsFor` and reading the BASE nav for the same reason: an override is
presentation and where everybody lands is behaviour). With the module installed
that is still Characters, so a player's first screen after signing in does not
change. Deliberately generic and deliberately not in the portal layout — the
admin index is the same question with a hardcoded answer, and if the two
logged-in areas ever become one this is what serves both.

`game_account_signup` goes with the rest of core's UO prose: the mode list, the
derived public flag, the validation and a Site Settings field whose help text
named Bridge.cfg. The row itself is untouched and module-uo reads it through
ctx.settings — the data stays, the semantics move.

KNOWN BREAK, accepted by the org lead: the shipped Android app reads
`gameAccountSignup` off `/public/settings` (PublicDto.kt:80). The field has a
`= false` default so nothing crashes; the app silently stops offering
game-account creation until it reads the module's `/public/shard/features`
instead. Out of scope here, recorded in the Android plan, and it lands well
before this workstream's cutover reaches `main`.

620 server + 161 client tests. Manifest 158 public + 2 internal, unchanged;
routes.guards unchanged. The OpenAPI spec loses exactly one property, and only
because it was hand-written in swagger.js — regeneration alone would have left
the spec documenting a field core no longer returns.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 18:39:55 -05:00
5b5006c365 feat(modules): a third slot, nav icons, and api.BASE (phase 3, slice 3)
The three things core owes the client half before it can leave, all additive,
all MODULE_API 1.2.0 → 1.3.0.

`player.invite.accepted` is the third extension slot. Core's invite page owned a
UO game-account step — it read a `gameAccountSignup` flag out of core's own
settings and posted to a shard route — and an invite is a core concept that
staff receive too, so the page stays and its optional next step becomes a slot.
Named for the place, like the other two. Whether there is a step at all is the
filling module's call, made from data core does not have; core keeps the shell,
the skip control and the destination.

`icon` on a nav item, because without it the six extracted UO rows would have
been the only text-only entries in a sidebar where every other row has a glyph.
Core supplies no fallback — an invented one is core making a presentation choice
for content it knows nothing about. `icon` was already among the fields an
override may not touch, so the concept predates a module being able to send one.

`api.BASE` was in §3.5 from the first draft and never actually published.
`request` is fetch-only, so an EventSource builds its own URL, and the shard's
live feed is two of them; the alternative is a module hardcoding `/api/v1`,
which asserts something about core that core has not promised.

`AcceptInvite` is the one legitimate reader of `extensionFor` outside Slot.jsx:
the answer decides a NAVIGATION, not a decoration. Decoration goes inside
`<Slot wrap>`, which is why `hasExtension` stayed deleted.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 18:39:36 -05:00
91964c8898 Merge pull request 'feat(modules): client extension slots (phase 3, slice 2)' (#138) from feature/module-client-slots into edge
Reviewed-on: #138
2026-08-11 21:53:41 +00:00
d667565ae7 refactor(modules): move core's UO page content behind the two slots
All checks were successful
PR Checks / client-build (pull_request) Successful in 26s
PR Checks / server-tests (pull_request) Successful in 29s
PR Checks / bot-install (pull_request) Successful in 8m46s
Core declares site.footer.status and admin.users.detail in main.jsx and fills
both itself, under owner id `core` -- the client twin of registries.registerCore()
and the same trick useShardFlags already uses. The rendered page is unchanged;
what changes is that the content now arrives the way a module's will.

The footer's Shard Status link becomes ShardStatusLink.jsx, and UserDetail's six
UO sections become UserShardSections.jsx. Both are files rather than inline
markup so that the client half of phase 3 deletes a registration and a file
instead of editing a core page under extraction pressure -- which is also what
proves the mechanism before anything depends on it.

The user-detail slot is handed userId and not scope. api.admin.userShard is a UO
binding that leaves core with the client half, so a slot passing it would hand a
module something core is about to delete; an extension builds its own client for
the routes it registered at the other end. Core's own fill now does exactly what
the module will.

Verified in a browser against a real chunk (MODULE_API.md 7.7): a throwaway
module fills both slots and renders its own label and target in the footer with
core's linkStyle, and receives userId on the admin page; a deliberate render
failure is contained to that one spot with the slot named in the console; core's
own fills leave the pages byte-identical to before; and with no module installed
both slots render nothing. Zero CSP reports throughout.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 16:45:22 -05:00
1d1350558b feat(modules): client extension slots (phase 3, slice 2)
The client twin of the server's declareSlot/registerExtension, and the same rule
in both halves: core declares a slot, only core declares one, and at most one
module fills it. Core renders <Slot name> and gets nothing back when the slot is
unfilled, so an instance with no module installed renders exactly what it
rendered before -- the same untouched-path guarantee withModuleNav makes.

A slot is named for a PLACE, never for a meaning. Core supplies the position and
the styling; the label, the target, the data and whether anything renders at all
are the module's. The moment core types a slot by its content it has re-acquired
the game semantics phase 3 exists to remove.

This is the one place the client registry is not fail-open. An unknown slot, a
non-component and a second fill all throw, matching checkExtensionShape
server-side, because a dropped nav row costs a link the viewer can reach another
way while a silently dropped extension is invisible to everyone including its
author. A throw is always a programming error and never a race: core declares in
its own bundle and every module chunk is a deferred script injected after it.

Reading stays fail-safe -- undeclared and unfilled both read null -- and a
filling component renders inside an error boundary. That asymmetry is where the
client differs from the server: a module route that throws costs the module's own
page, but an extension throws inside CORE's, and the whole reason core keeps
ownership of that page is that it stays usable.

Core decorates a slot through <Slot wrap>, not by asking whether it is filled.
The obvious alternative is right about the unfilled case and wrong about the
failed one -- the extension is filled, so the separator renders, and then the
component throws into the boundary and leaves the separator behind on its own.
wrap puts core's decoration inside the boundary where it shares the extension's
fate. Found in a browser, with the footer's separator, which is the only place
either could have been found.

MODULE_API_VERSION 1.1.0 -> 1.2.0, both halves: the two state ONE version.
Contract: docs/website/MODULE_API.md 3.7.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 16:45:08 -05:00
7a236cad8b Merge pull request 'refactor(modules)!: move the UO server half out to module-uo (phase 3, slice 1)' (#137) from feature/module-extract-server into edge
Reviewed-on: #137
2026-08-11 21:06:23 +00:00
f5e6025dcc test: re-point core's suite at what core still owns
All checks were successful
PR Checks / bot-install (pull_request) Successful in 18s
PR Checks / client-build (pull_request) Successful in 27s
PR Checks / server-tests (pull_request) Successful in 29s
25 of 82 test files left with the module. Three that core keeps needed splitting
rather than moving, and the split is the boundary in each case.

announceJobs.test.js keeps the announce PIPELINE -- the shared backoff schedule,
the parent-status rollup, core's Discord leg -- and loses the town-crier text
building and classification, which are a module's leg. pushDispatch.test.js
keeps the SSRF guard and publish() delivering a content-free tickle, and loses
mapShardEvent and the shard fan-out, which are a module's catalog.

playerRouteAccess.test.js is the one worth explaining. It guards a real past bug
-- an admin 403'd off their own characters -- and it did so through
/player/shard/accounts, which is now module-owned. The guarantee it protects is
CORE's, though: /player/* is role-agnostic self-service, staff are a superset of
players. So it stays here and asserts that through /player/appeals, a core route
with the same gate. Moving it would have left core with no test of its own tier
rule, which is precisely what regressed once before.

The remaining updates are core's own tests catching up: ctx has four more
members, registerCore now registers only what core owns (one stream, one leg, no
filled slot), and the extension-slot test asks for the DECLARED slot's router
rather than the filled one, since core declares it and a module fills it. The
gated-surface floor drops from >100 to >50 -- it is there so a filter matching
nothing fails loudly, not to track core's exact route count.

616 core tests and 160 client tests pass; the module's own suite is 351.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 12:08:26 -05:00
39d731d87a refactor(modules)!: move the UO server half out to module-uo
40 files, ~9,674 lines, 27 of 68 tables. Core no longer contains anything that
knows what a shard is.

BREAKING for a deployment only in the sense that the module must be installed
for these URLs to answer -- no URL moved. routes.manifest.json goes 228 -> 158
public routes here, and the 70 that left reappear byte-identical when the module
is loaded: verified by generating the manifest against core+module and diffing
it against the pre-extraction file. Zero missing, zero added, and routes.guards
identical across all 228, so no auth gate moved either.

The five tier mounts are gone from public/admin/player index.js and are still
served: the loader mounts them onto the same routers after every core mount.
That ordering is also what keeps the prefixes unclaimable -- the collision check
asks the live router what core owns, so a second module claiming /shard is
rejected against the mounts actually present rather than against a list.

server.js loses its eight UO call sites to the module's onBoot/onShutdown.
schema.sql loses its 27 shard_*/uo_link_* statements; the two that FK into users
are why the fragment replays AFTER core's schema, and no core table ever
referenced a module table, which is what makes core still able to boot alone.

Verified against a running server with the module installed: it loads, mounts
five prefixes, replays 35 statements, warms up and reaches `started`; public
shard and atlas routes answer 200 with real data (800 creatures, 6,455
spawners), admin and player answer 401 from core's tier gates, and the extension
slot answers at /admin/users/:id/shard/*. Core's own SPA renders the shard and
atlas pages unchanged against the module-served API, with no console errors and
no CSP reports.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 12:08:03 -05:00
f50541f374 feat(modules): ctx additions and the post-hook registry (API 1.1.0)
Everything the extraction needed from core that ctx did not already offer.
Additions only, so minor.

ctx.activity.log, because an admin action a module performs has to land in
core's one audit log or the trail has a hole exactly where a module operates the
game -- a module keeping its own log would be a second place to look, which in
practice means a place nobody looks. Write-only; reading the log is the admin
panel's job and it spans every actor.

ctx.users.getById, one function for one caller: the admin.users.detail slot
router needs the user its prefix names. ctx.site.baseUrl, because a module has
to build absolute links and §2.7 forbids it reading core's APP_BASE_URL -- a
getter, not a captured string, so it cannot go stale against the env.

ctx.middleware.rateLimit is core's makeLimiter, plus accountChangeLimiter handed
over whole. The split is deliberate: a module states its own window and cap
because it knows what its endpoints cost, and takes the plumbing from core so
there is one express-rate-limit in the process and one place a breach is logged.
accountChangeLimiter is shared policy -- core's /auth/me and /player/account sit
behind the same counter -- so a module's account-change route has to land IN it
rather than beside it. marketLimiter was UO policy living in core's file and
leaves with the route it guards.

registerPostHook is the fourth registry, and the last thing binding core to the
module. Core's post controller called newsGump.syncPost directly: core's CMS
naming a UO file. It now publishes what it already knows and a subscriber
decides what to do with it. Not folded into registerAnnounceLeg, which fires on
the same transition, because a leg is a one-shot DELIVERY with retry and
classification while a post hook maintains idempotent STATE, runs on delete as
well as save, and refreshes silently on an edit.

Also fixes a real loader defect the extraction exposed: schema table names were
matched against the RAW file, so a fragment whose header says "every CREATE
TABLE carries IF NOT EXISTS" was rejected for a prefix violation on a table
called `carries`. module-uo's fragment hit exactly that. Both scans now read
split statements, which strip comments -- the same class of bug as a boundary
check failing on its own documentation.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 12:07:45 -05:00
b649345484 Merge pull request 'feat(modules): mount the modules directory as a volume (phase 2, PR 9)' (#136) from feature/module-compose-volume into edge
Reviewed-on: #136
2026-08-11 05:58:18 +00:00
a103e0ce10 feat(modules): mount the modules directory as a volume (phase 2, PR 9)
All checks were successful
PR Checks / bot-install (pull_request) Successful in 21s
PR Checks / client-build (pull_request) Successful in 32s
PR Checks / server-tests (pull_request) Successful in 1m39s
Closes Phase 2. Modules live on a mount, never in the image — that is what
lets an operator add one to a pull-only deployment without building anything.

`./modules` is a bind mount rather than a named volume: placing a module
directory by hand is a supported install (MODULE_SYSTEM.md §2.5), and that has
to be doable from the host rather than through `docker cp`. Read-write, because
the admin panel's install/uninstall unpacks and removes directories there.

The directory is tracked via its README so it exists in the checkout with the
operator's own ownership — Docker recreates a missing bind-mount source as
root:root, which the container user could not then write. `.dockerignore`
excludes it so a module in the builder's working tree can never ship inside an
image.

Also corrects the route-manifest generator's list of filesystem-conditional
mounts, which never picked up `/modules` when PR 7 added it. Comment only; the
generator filters on an allowlist, so its behaviour was already right.

Verified against a real container, not just a parsed compose file: image
carries an empty node-owned /app/modules despite a module in the build context;
a module on the bind mount loads, mounts, replays and reaches `started`;
`/api/v1/public/modules` lists it; the chunk serves from the entry's directory
only (server source and module.json 404) with `no-cache`; the injected tag
follows core's bundle; and in Chrome the page renders on first paint inside
core's PublicLayout with its nav row interleaved into core's public nav, under
enforced `script-src 'self'` with zero CSP reports and no console errors.
Removing the directory by hand reconciles the row to `startup_failed`/`require`
and leaves core healthy with no injection.

933 server + 160 client tests pass, manifest unchanged at 230 routes, swagger
regenerates byte-identical.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 00:48:48 -05:00
0a3f1eb9fa Merge pull request 'feat(modules): interleave module nav items and derive moderator confinement (phase 2, PR 8)' (#135) from feature/module-nav-interleave into edge
Reviewed-on: #135
2026-08-11 05:29:40 +00:00
a45a3d120a feat(modules): interleave module nav, derive moderator confinement
All checks were successful
PR Checks / bot-install (pull_request) Successful in 17s
PR Checks / server-tests (pull_request) Successful in 1m34s
PR Checks / client-build (pull_request) Successful in 8m58s
Phase 2, PR 8 of docs/website/MODULE_SYSTEM.md 2.7 - the nav half PR 7
deferred, plus the two seams 1.4 and 1.5 asked for.

withModuleNav (client/src/modules/nav.js) merges an installed module's rows
into core's three navs BEFORE the admin-override merge, and that ordering is
the design. applyNavOverrides and buildPublicNav are keyed by `to` and drop
any key their base array does not declare, so rows appended after the merge
would be unorderable, unrelabellable and unhideable in Admin - Navigation.
Today's UO rows are all three of those things, so appending would make the
extraction a visible regression for anyone who has ever edited their nav.
Merging first means a module row is an ordinary row downstream: nothing in
navOverrides.js, NavEditor.jsx or the layouts knows a module exists.

MOD_PATHS is gone. Moderator visibility and the redirect that confines a
moderator both derive from each row's own `roles`, in the new plain-JS
lib/adminNav.js (plain so the DOM-less runner can reach it). Two rows move,
both toward what the server already permitted: Dashboard, whose roles had
always named moderator, and My Characters, which is ungated self-service.

That also fixes a defect predating the module system. The redirect was a
THIRD hardcoded list - three path prefixes against MOD_PATHS' five paths -
and they disagreed about /admin/houses, so a moderator who clicked Houses in
their own sidebar was bounced back to Moderation. The derived allow-list is
computed from the BASE nav, never the override-merged one: an override is
presentation and must not move an authorization boundary either way.

The feature seam (modules/features.jsx + modules/featureGate.js) resolves a
row's `feature` against the provider its OWN module registered, so the
namespace comes from the registration and no string carries a parsed prefix.
Core registers useShardFlags under the owner id `core` - the client twin of
registries.registerCore() - so the ten shard-gated header rows already run
through the seam and Phase 3 deletes a registration instead of rewriting
SiteHeader. Every unknown fails open: no provider, a null answer while a
fetch is in flight, or a junk return all show the link, because the server is
the gate and hiding a page from someone entitled to it is the worse mistake.

933 server tests (unchanged - this PR is client-only), 160 client tests
(+37). routes.manifest.json unchanged at 230 routes; the OpenAPI spec
regenerates byte-identical.

Re-ran the MODULE_API.md 7.7 browser smoke, since this is the seam that rule
exists for. A throwaway module registering nav in all three areas and a
provider granting one flag and withholding another: the row lands inside
core's Moderation group rather than an appended block, the withheld row does
not render, a moderator reaches both /admin/houses and the module's admin
page, and an admin can relabel a module row and have it persist and apply.
Zero CSP reports, zero console errors.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 23:42:02 -05:00
e3c999b704 Merge pull request 'feat(modules): the client registry, window.__rg and the chunk's script injection' (#134) from feature/module-client-registry into edge
Reviewed-on: #134
2026-08-11 04:01:59 +00:00
e0927bc255 feat(modules): the client registry, window.__rg and the chunk's script injection
All checks were successful
PR Checks / bot-install (pull_request) Successful in 21s
PR Checks / client-build (pull_request) Successful in 30s
PR Checks / server-tests (pull_request) Successful in 1m37s
Phase 2, PR 7 of docs/website/MODULE_SYSTEM.md 2.7 — the client half's
delivery. A module's prebuilt chunk is served, injected, handed core's React
and its UI kit, and its routes are rendered by App.jsx. The registry is empty
on a bare core, so nothing an operator can see changes.

Client:
  - modules/registry.js — registerRoutes/registerNav/registerFeatureProvider,
    with the URL namespace written by core, never by the module
  - modules/shared.js — window.__rg: React, react-dom/client, react-router-dom,
    react/jsx-runtime, the registry, the seven-member UI kit and the request
    primitive, frozen
  - App.jsx reads routesFor for all three areas; nav consumption is PR 8
  - main.jsx publishes the global, then mounts on DOMContentLoaded

Server:
  - the loader validates client.entry and publishes clientChunks() and
    clientEntryUrls(); an entry in the module root is rejected, because the
    directory it sits in is what gets served
  - app.js mounts each chunk at /modules/<id>/ behind the module's state guard
    with no-cache; anything else under /modules is a 404, not the SPA shell
  - htmlShell injects the tag before </body>, so core's bundle runs first
    wherever a bundler puts it

Found by loading a real chunk in a browser, and fixed here: core mounted before
any module chunk had evaluated, because document.readyState during a deferred
script is 'interactive', not 'loading'. Every test passed against that build.
The smoke is written down in MODULE_API.md 7.7.

933 server tests (+23), 123 client tests (+14). routes.manifest.json unchanged
at 230 routes; the OpenAPI spec regenerates byte-identical.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 22:54:16 -05:00
fe83c91ba9 Merge pull request 'feat(modules): publish the installed-module list at /api/v1/public/modules' (#133) from feature/module-public-endpoint into edge
Reviewed-on: #133
2026-08-11 03:16:49 +00:00
291c30f6ff feat(modules): publish the installed-module list at /api/v1/public/modules
All checks were successful
PR Checks / client-build (pull_request) Successful in 26s
PR Checks / server-tests (pull_request) Successful in 1m33s
PR Checks / bot-install (pull_request) Successful in 8m45s
Phase 2, PR 6 of docs/website/MODULE_SYSTEM.md 2.7 — the first module-system
URL a client can see. The SPA and the Android app feature-detect against the
capabilities a module declares; the shape is settled in MODULE_API.md 2.9.

Four decisions, and what is absent from the payload is most of the design:

* started modules only. A module that is disabled or failed to load is
  ABSENT, exactly as 4.4 already leaves its routes and its nav absent, so a
  client renders a site without that capability rather than advertising one
  that 503s.
* no state, failure_stage or failure_reason. Where a module broke belongs to
  the admin Modules screen, and the reason is an exception string from inside
  core — not anonymous-visitor business.
* no client chunk URL. htmlShell injects a script tag per started module
  (3.1.3), so the browser is handed the tag rather than a URL to fetch. This
  endpoint feature-detects; it does not load. MODULE_SYSTEM 2.6 step 4 is
  amended to match (API 6.7).
* no siteMode gate and no database — the same class as /public/status and
  /public/version, so a client can still feature-detect during maintenance.

It is a capability router of its own rather than a fifth singleton in
site.router.js, and that is load-bearing: the loader's prefix-collision probe
reads the live tier stack and skips root-mounted layers, because a use('/', ...)
matches every path. A route inside the root-mounted site router would be
invisible to it — mounting use('/modules', ...) is what makes "no module may
claim /modules" a rule the loader enforces.

910 tests pass (+9, every one on the boundary — what must NOT appear).
routes.manifest.json gains exactly the one route and routes.guards.json records
it with an empty gates list, which is itself the assertion that it is ungated.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 22:03:22 -05:00
85f563fc16 Merge pull request 'feat(modules): boot/shutdown hook dispatch and the installed_modules reconcile' (#132) from feature/module-lifecycle into edge
Reviewed-on: #132
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-11 02:34:46 +00:00
32ed8e4411 fix(test): stop the suite reaching a real database, and make it exit
All checks were successful
PR Checks / client-build (pull_request) Successful in 25s
PR Checks / server-tests (pull_request) Successful in 1m34s
PR Checks / bot-install (pull_request) Successful in 8m45s
`npm test` never terminated. Twenty-two test files omitted the two lines that
point the pool at a dead port, so utils/db.js -- which builds its mariadb pool at
require time and calls dotenv.config() itself -- picked up server/.env and opened
five live connections to the developer's MariaDB. The tests still passed, because
they stub their models and never issue a query; the only symptoms were a process
that never exited and five connections held for as long as it lived. Thirty
stranded workers is 150 connections, which is the whole server's limit, and that
is the "too many connections" this workspace has hit before.

The convention was right and only ever as good as the next test file's memory of
it, so it moves into the harness: test/_setup.js is loaded with --require by the
npm script, ahead of the test file it hosts, which is the only moment early
enough to matter. It pins the dead port -- dotenv does not overwrite an existing
variable, so an explicit DB_PORT= still wins for anyone who wants a live database
-- and closes the pool after the file's tests, so the process exits at once
instead of waiting out the driver's connect retries. The per-file preambles stay:
they keep `node --test test/one.test.js` safe on its own.

Two supporting fixes:

- db.close() is idempotent. pool.end() throws "pool is already closed" on a
  second call, and closing twice is now normal rather than exceptional -- the
  harness closes the pool for every file on top of the suites that close it
  themselves, and a SIGINT followed by a SIGTERM already reached the shutdown
  handler twice.
- test/_helper.js's close() destroys open connections. server.close() only stops
  accepting and waits for existing connections to end, and node's global fetch
  keeps its sockets alive, so the listener outlived the test that created it --
  invisible until now, because the pool was holding the process open anyway.

announceJobs.test.js alone: 120s+ hang -> 0.35s. The whole suite now finishes in
~75s where it previously did not finish at all: 901 tests, 901 pass, verified
three times on CI's exact platform (node:20 on Linux, via Docker).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 21:27:47 -05:00
21196466ed feat(modules): boot/shutdown hook dispatch and the installed_modules reconcile
All checks were successful
PR Checks / bot-install (pull_request) Successful in 23s
PR Checks / server-tests (pull_request) Successful in 1m44s
PR Checks / client-build (pull_request) Successful in 9m1s
Phase 2, PR 5 of docs/website/MODULE_SYSTEM.md 2.7. api.onBoot/api.onShutdown
stop throwing, server.js gains one call on each side, and the 2.4 state machine
finally runs against real outcomes -- which is what makes 4.5's `disabled` 404
leg reachable for the first time.

Dispatch and reconcile live in src/modules/lifecycle.js rather than in the
loader, for the reason the schema replay does: routeManifest.js and swagger.js
both require app.js against a dead pool, so the loader may not reach the
database. The two halves meet at exactly one function, loader.setState(), so the
in-memory record the dispatch guard reads and the row the admin panel reads are
moved together and cannot disagree.

Four decisions, all recorded in 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 ever filled. The four steps
  readManifest covers in one pass label themselves; the rest are inferred from
  how far load() had got, and an unlabelled throw is recorded against the step
  that was running rather than guessed at.
- A row whose directory is gone is marked startup_failed rather than left
  claiming `enabled` -- the boot reset has just moved it there, and a row
  claiming to be enabled for a module that is not on the volume is the one state
  that is simply untrue. An uninstall leaves `disabled`, which the reset never
  touches, so this catches only a hand-deleted directory.
- Core's eight UO boot call sites stay in server.js until Phase 3. Unlike a
  registered announce leg, a boot call site already has somewhere to live, so
  moving it now would be extraction done early in a phase whose exit criterion
  is that nothing changes.
- 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 operator's switch wins over everything: a disabled module is guarded, not
booted, and does not have its failure re-recorded, or an outcome would silently
switch it back on next boot. Every database write in the reconcile is
individually caught -- a row that will not update is worse reporting, never a
failed boot.

900 tests pass (17 new). routes.manifest.json is unchanged at 229 routes and the
OpenAPI spec regenerates byte-identical.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 20:32:00 -05:00
39eaae90a8 Merge pull request 'feat(modules): the three de-entanglement registries, with core as the registrant' (#131) from feature/module-registries into edge
Reviewed-on: #131
2026-08-10 23:18:57 +00:00
97f19b4221 docs(modules): note that registerExtension's spec-file argument is core-only
All checks were successful
PR Checks / bot-install (pull_request) Successful in 18s
PR Checks / client-build (pull_request) Successful in 26s
PR Checks / server-tests (pull_request) Successful in 1m40s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 17:53:59 -05:00
6195c76d61 feat(modules): the three de-entanglement registries, with core as the registrant
All checks were successful
PR Checks / client-build (pull_request) Successful in 23s
PR Checks / server-tests (pull_request) Successful in 1m39s
PR Checks / bot-install (pull_request) Successful in 8m49s
Phase 2 PR 4 of docs/website/MODULE_SYSTEM.md §2.7. Adds server/src/modules/registries.js
and moves core's own notification streams, announce leg and users-detail routes
behind it, so the three seams §1.8 and §1.9 named are exercised on every boot
before any module depends on them.

Registering is validate-then-commit per registrant: the loader stages what a
module claims and the second pass commits it, so a module that throws halfway
through register() — or fails checkDeclared after it — leaves nothing behind.
That is the registry-side twin of PR 2's second-pass mount rule.

Four decisions, all the recommended option:

- announce legs became a child table. `announce_job_legs` replaces the
  towncrier_*/discord_* column groups, so the leg set is data: core registers
  `discord`, module-uo will register `towncrier`, and a module cannot ALTER a
  core table to add its own. Backfill is guarded on information_schema (a
  SELECT of a dropped column is a parse error, not a runtime one) and the
  columns go with DROP COLUMN IF EXISTS. Verified against the live dev DB:
  three legacy jobs migrated faithfully, three replays, no duplicates.
- `mapEvent` dropped from registerNotificationStreams. §1.8 already inverts the
  push path so a module owns fromShardEvent and calls core's publish() with a
  stream id it resolved; a second mapping mechanism was a leftover. The public
  safety filter, the kinds it reads and the streams it protects now live in one
  file and move together.
- core registers through the same staging area a module uses, via an explicit
  registries.registerCore() in app.js before modules.load().
- core's six /admin/users/:id/shard/* paths now go through the
  `admin.users.detail` slot, and getUser moved back to admin.controller.js.

Found on the way, and the reason two build tools changed:

- scripts/routeManifest.js could not decode a parameterised mount. Its
  unwinder expected `(?:([^\/]+?))`; express 4.22 emits `(?:\/([^/]+?))` with
  the separator inside the group. The branch had never run. It threw rather
  than guessing, which is what it is for.
- swagger-autogen cannot follow a route into an extension slot — the slot's
  router is created by declareSlot() and filled later, so there is no literal
  mount for a static parse. Regenerating deleted 407 lines and printed
  `Swagger-autogen: Success`, the spike's exact failure (MODULE_API.md §7.4).
  swagger/slotSpecs.js generates a fragment per filled slot and re-roots it at
  the prefix the router actually hangs at in the live app — read from the
  express stack via routeManifest's own mountPath, so the manifest and the spec
  cannot disagree. swagger/mergeSpec.js is the merge helper core owes for
  module fragments anyway (§6.1a), proved here against core's own slot first.

884 tests pass (856 before). routes.manifest.json is unchanged at 229 routes.
The OpenAPI spec diff is two lines of intent: the retry endpoint's summary, and
its `leg` no longer being a fixed enum.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 17:47:59 -05:00
bd749d4f1f Merge pull request 'feat(modules): replay module schema fragments after core's (phase 2, PR 3)' (#130) from feat/modules-schema into edge
Reviewed-on: #130
2026-08-10 22:06:44 +00:00
2892d01b24 feat(modules): replay module schema fragments after core's
All checks were successful
PR Checks / bot-install (pull_request) Successful in 22s
PR Checks / client-build (pull_request) Successful in 28s
PR Checks / server-tests (pull_request) Successful in 10m9s
Phase 2, PR 3 of docs/website/MODULE_SYSTEM.md 2.7. ensureSchema() now replays
every installed module's schema fragment immediately after core's schema.sql,
per MODULE_API.md 2.6.

The work splits across two files on the line of whether a database is needed to
know the answer. loader.js VALIDATES a fragment at load time, before anything is
mounted, because every rule 2.6 states about the SQL is knowable by reading it;
a module that breaks one never mounts (4.4, left column). modules/schema.js
EXECUTES it, so the only failures there are the ones the database alone could
report, and those are post-mount and answer 503 (4.4, right column).

Validation is a leading-verb allowlist -- CREATE, ALTER, INSERT, UPDATE, the
four core's own schema.sql uses -- rather than the DROP denylist 2.6 words it
as. A fragment is replayed on every boot, so TRUNCATE and DELETE would empty a
table at each restart and RENAME would fail at the second one; a denylist only
ever bans what somebody thought of. A CREATE TABLE missing IF NOT EXISTS is
rejected for the same reason: it works once and fails every boot after, which
presents to an operator as a module that broke on restart.

The splitter moves to utils/sqlStatements.js so core's schema and a fragment are
split by literally the same code, which is what 2.6 promises. It is its own file
rather than an export of utils/db.js because the loader validates fragments at
require time and must not drag the mariadb pool into app.js's require chain.

The replay sits outside ensureSchema's wait-for-the-database retry loop: a
fragment that throws is one module's failure, not a signal the database is
coming up, and retrying core's whole schema nine more times over one module's
bad SQL would turn a 503'd module into a two-minute boot.

Found while wiring it: db/seed.js calls ensureSchema() standalone for
`npm run seed`, without ever requiring app.js, so the loader has not scanned and
fragments()'s 7.6 throw would have broken seeding outright. The replay asks
isLoaded() and logs the skip rather than swallowing it -- a booting server
quietly getting no module tables is the thing 7.6 exists to prevent.

Verification:

- 856 server tests pass, 14 new. moduleSchema.test.js injects the query fn, so
  the exact statements and their order are asserted with the pool at a dead port
  like every other suite.
- routes.manifest.json and routes.guards.json diffs are zero lines, 229 routes
  -- the phase 2 exit criterion. swagger-output.json regenerates byte-identical.
- Run for real against the local MariaDB with two fixture modules: a good
  fragment created its table, applied its ALTER and seeded its row; a fragment
  whose SQL passes validation but the server rejects (`id NOTATYPE`) marked only
  that module startup_failed, its route answering 503 while the other answered
  200; a second ensureSchema on the same database was a clean no-op.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 16:56:58 -05:00
7780fb033b Merge pull request 'feat(modules): the filesystem module loader (phase 2, PR 2)' (#129) from feat/modules-loader into edge
Reviewed-on: #129
2026-08-10 21:31:24 +00:00
ec1ca7e794 feat(modules): the filesystem module loader
All checks were successful
PR Checks / bot-install (pull_request) Successful in 16s
PR Checks / client-build (pull_request) Successful in 24s
PR Checks / server-tests (pull_request) Successful in 10m4s
Phase 2 PR 2 of docs/website/MODULE_SYSTEM.md 2.7. Adds
server/src/modules/{loader,semver,version}.js: the synchronous scan of
MODULES_DIR, manifest validation, prefix and table-name collision
rejection, per-module try/catch and the mount into the three tier
routers behind the MODULE_API.md 4.5 dispatch guard.

Two decisions the contract left open, both now written up there:

- The load trigger is one explicit modules.load(tierRouters) call in
  app.js, not a lazy scan (API 7.6). Accessors throw until it has run,
  because "no modules installed" is a real answer a caller must not be
  handed by accident.
- Whether core owns a prefix is asked of the live tier routers via
  express's own layer.match(), skipping root-mounted layers, rather than
  a hardcoded table -- the spike's was already stale when written
  (API 4.3).

Mounting is a second pass after every module is validated. Doing it
inside the scan loop makes the first module's layers indistinguishable
from core's, so the second module claiming a taken prefix is told it
collided with core and the module-versus-module check is unreachable.

registerExtension/NotificationStreams/AnnounceLeg and onBoot/onShutdown
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.
No schema replay, no boot dispatch, no installed_modules reconcile --
those are PRs 3 and 5, and until PR 5 a record's state is in memory only.

No module ships on the volume, so nothing an operator or client can see
changes: 842 tests pass, routes.manifest.json is unchanged at 229 routes
and swagger-output.json regenerates byte-identical.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 14:35:27 -05:00
dcf6ef1886 Merge pull request 'feat(modules): installed_modules and the module state machine (phase 2, PR 1)' (#128) from feat/modules-state into edge
Reviewed-on: #128
2026-08-10 19:04:58 +00:00
3add0063bf feat(modules): installed_modules and the module state machine
All checks were successful
PR Checks / bot-install (pull_request) Successful in 17s
PR Checks / client-build (pull_request) Successful in 27s
PR Checks / server-tests (pull_request) Successful in 1m35s
Phase 2 PR 1 of the module system (docs/website/MODULE_SYSTEM.md 2.7). The
table and the state machine only: no loader, no routes, no boot wiring, so
nothing an operator or a client can see changes and the route manifest diff
is zero lines.

The five states of 2.4 live in one `state` column: installed -> enabled ->
started, with disabled and startup_failed as the recoverable ones. 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 (MODULE_API.md 4.1), which is what keeps routes.manifest.json
generatable against a dead database.

Two rules the model owns and the boot path will lean on:

- Every boot resets each non-disabled row to `enabled` and clears its
  recorded failure, so a startup_failed module is retried on the next restart
  and a fixed one recovers with no admin-panel visit. `disabled` is the one
  operator decision rather than outcome, so it survives untouched -- and a
  disabled module's failure is a no-op, never a re-enable.
- A failure carries the stage it happened at, and every non-failing
  transition clears it, so a running module can never show a stale reason.

An illegal move throws instead of writing a row that misrepresents the state,
except on the two boot-path softenings noted above, because one module's
failure must never become everybody's.

22 model tests over an in-memory fake; the SQL and the DDL were round-tripped
against a real MariaDB separately.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 06:35:57 -05:00
f1dda8fe66 Merge pull request 'ci: run PR checks on pull requests into edge as well as main' (#127) from ci/pr-checks-on-edge into main
Some checks failed
sync-project-tree / sync (push) Successful in 9s
Build container images / build (push) Successful in 56s
SonarQube / analysis (push) Successful in 4m9s
Build container images / deploy (push) Failing after 11m31s
Reviewed-on: #127
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-10 07:42:15 +00:00
4691fd6633 ci: run PR checks on pull requests into edge as well as main
All checks were successful
PR Checks / bot-install (pull_request) Successful in 20s
PR Checks / server-tests (pull_request) Successful in 1m37s
PR Checks / client-build (pull_request) Successful in 9m13s
Long workstreams land phase by phase on `edge` and reach `main` as a single
cutover. With `branches: [main]` alone, every one of those phase PRs merges
with no checks at all -- no server tests, no client build, no bot install --
and the whole workstream runs blind until the cutover, where the breakage
arrives all at once and un-bisected.

This is not hypothetical: it is what happened to all nine Android M12 phase
PRs in the Android-app repo, whose pr-checks.yml carries the same trigger.

It matters for the module system specifically because Phase 2's exit
criterion IS a CI result -- a zero-line routes.manifest.json diff and a
passing suite -- and that manifest is the frozen URL surface protecting
three shipped clients. A branch accumulating work for weeks needs the gate
more than main does, not less.

Landing before the module-system edge branch is cut, so the first phase PR
is checked. build-images.yml is deliberately untouched: it triggers on push
to main, so images publish and production rolls at the cutover and never
before.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 02:31:43 -05:00
265042eaa5 Merge pull request 'feat(theming): admin-configurable theme, brand assets and navigation (edge → main)' (#126) from edge into main
All checks were successful
sync-project-tree / sync (push) Successful in 16s
Build container images / build (push) Successful in 1m27s
Build container images / deploy (push) Successful in 43s
SonarQube / analysis (push) Successful in 4m13s
Reviewed-on: #126
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-08 06:19:34 +00:00
18815f4c7a Merge pull request 'feat(theming): dropdown sections and added links in the public header (phase 10)' (#125) from feat/theming-nav-phase-10 into edge
All checks were successful
PR Checks / bot-install (pull_request) Successful in 22s
PR Checks / client-build (pull_request) Successful in 38s
PR Checks / server-tests (pull_request) Successful in 10m17s
Reviewed-on: #125
2026-08-08 06:07:15 +00:00
15cefe5ea1 fix(theme): state .pill's line-height so a button pill matches a link pill
The public header's dropdown trigger is a <button class="pill"> sitting in a row
of <a class="pill"> links, and it rendered ~7px shorter.

It was not failing to pick up the theme: font-size, font-family, padding, border
and box-sizing all matched exactly. The one property that differed was
line-height, because form controls do not inherit it — the UA stylesheet gives
<button> `line-height: normal` (~1.15), while the anchors inherited body's 1.6.
38.02px against 31px, which is precisely 22.016 - 15.8.

Stating it on .pill fixes it at the source rather than patching the one button:
every other property in that rule is already explicit for the same reason, and
this was the remaining gap. The value matches body's 1.6, so no link pill
changes. The ~70 <button class="pill"> elsewhere in the admin gain the same 7px
and now line up with the .btn buttons they sit beside.

.btn has the same latent difference and is deliberately left alone: it is used on
80 buttons and 2 anchors, they never appear on the same row, so nothing is
visibly wrong and the blast radius is not worth it.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 00:56:50 -05:00
b517d7b2df feat(theming): dropdown sections and added links in the public header
Phase 10 of docs/website/THEMING_AND_NAV.md, asked for before the edge -> main
cutover. An admin can now create dropdown sections in the public header, organise
the coded entries into them, and add links of their own.

This deliberately amends §7, which said the override layer "cannot introduce a
`to` that is not already in the hardcoded NAV array". That stays true of every
CODED entry; an admin may now also add a link, restricted to a same-origin path —
no scheme, 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.

The invariant is kept structurally rather than by vigilance. Coded entries live
in an `items` map whose keys must be routes the base array declares, so that map
cannot invent a route; everything that CAN name an arbitrary path lives in
`links`, which is the one place the path rule is applied — on both the write and
the read path.

nav_public therefore grew a { items, sections, links } wrapper. A bare map still
reads as the items map, and a nav with no sections still stores one, so this
changed nothing for a nav that does not use it. Free to do now because nothing
has shipped; after the cutover it would have needed a migration.

The Public tab gets its own editor. A public section is an entry in the
top-level order that the admin created and can drag among the pills, unlike the
admin sidebar's four coded sections, where only membership moves — that is a tree
rather than a list of groups. Deleting a section returns its entries to the top
level rather than removing them, which is the one destructive act this screen
could otherwise commit.

The dropdown opens on click and never on hover, and its trigger is not a link: a
hover menu is unusable on touch, and a trigger that navigates means tapping to
open takes you somewhere instead. Escape closes and returns focus, an outside
press closes, navigating closes, and Arrow Up/Down walk the items.

pruneNav applies the shard-feature gate inside a section and drops one it leaves
empty, so a dropdown never opens onto nothing.

Also fixes a bug this surfaced in the phase 6-8 code: the save path judged "does
this route still exist?" against the palette — the base array already filtered to
what the editing admin can see — so on the public header a feature-gated row's
override could never be carried through and would have been silently reset.
Membership is now judged against the full coded nav while the rows still come
from the palette.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 00:42:19 -05:00
78f994955c Merge pull request 'feat(theming): nav wiring and the admin nav builder (phases 6-8)' (#124) from feat/theming-nav-phase-6-8 into edge
Reviewed-on: #124
2026-08-08 05:11:33 +00:00
32a3ff104a feat(theming): wire the three navs and add the admin nav builder
Phases 6-8 of docs/website/THEMING_AND_NAV.md. The public header, the admin
sidebar and the player portal now read their override row, and /admin/navigation
writes them: rename, reorder by drag, hide, and — on the admin sidebar — move a
row into another existing section.

The merge always runs BEFORE the role and shard-feature filters in the layouts,
which are unchanged and remain the boundary. An override is presentation: it
cannot introduce a route, cannot touch a `roles` or `feature` gate, and a stored
`hidden: false` on a gated item shows nobody anything.

The design scoped these phases as client work, but the server had no way to
store a nav row: updateSettings validates and stringifies theme_visual and
brand_assets and lets everything else through, so a nav object would have been
written as "[object Object]" and read as absent for ever. utils/navOverrides.js
mirrors utils/brandAssets.js — strict on write with the offending key named,
forgiving on read. It validates shape only; whether a `to` exists is settled
client-side at merge time, because the base NAV arrays are client constants and
a server-side copy would be a second source of truth that drifts.

The nav editor cannot be hidden — its own toggle is disabled, the write path
drops `hidden` on that one `to`, and AdminLayout strips it again before merging,
which also covers a row edited straight in the database.

Orders are written only when the sequence actually differs from the code's, and
the comparison is restricted to the rows the editing admin can see, so renaming
one item does not pin the position of every other one and a role- or
feature-gated item missing from their palette is not mistaken for a reorder.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 00:02:33 -05:00
42a403ad2e Merge pull request 'feat(theming): brand-asset overrides and a cached, settings-aware HTML shell (phase 5)' (#123) from feat/theming-nav-phase-5 into edge
Reviewed-on: #123
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-08 02:12:46 +00:00
847cfd2d2b feat(theming): brand-asset overrides and a cached, settings-aware HTML shell
Phase 5 of docs/website/THEMING_AND_NAV.md: uploaded logo/hero/favicon
overrides on top of the BRAND_* env defaults, delivered through an HTML
shell that is no longer built once at boot.

- utils/htmlShell.js owns the shell lifecycle: rendered lazily, cached per
  process, invalidated on a brand_assets/theme_visual write with a 5-minute
  TTL so other workers converge. A settings-read failure renders the
  env-only shell and caches that, so a DB outage is not a failing query per
  page view, and with no rows the output is byte-identical to what app.js
  served before.
- POST /admin/settings/brand-asset/:slot uploads one asset and writes the
  row in the same call, so an upload never leaves an unreferenced file. It
  reuses the shared multer allowlist and only tightens it per slot: favicons
  are PNG-only and capped at 512 KB, logos at 1 MB, heroes at 8 MB. Refused
  files are unlinked before the response.
- utils/brandAssets.js constrains a stored asset to a same-origin path under
  /uploads, /brand or /assets — these are the only settings values written
  straight into the page as a URL. Strict on write, forgiving on read.
- The shell also carries the resolved theme as a <style id="theme-boot">
  block, removing the first-paint flash phases 3-4 deferred; SiteContext
  drops that block once a successful settings fetch has been applied.
- BrandLogo renders beside the MoonDot on all six shells and renders nothing
  when no logo is set, which is the shipped default.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 20:09:56 -05:00
02580ebda3 Merge pull request 'feat(theming): server-resolved theme engine and admin appearance UI (phases 3-4)' (#122) from feat/theming-nav-phase-3-4 into edge
Reviewed-on: #122
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-08 00:22:31 +00:00
3d6b2e23a7 feat(theming): server-resolved theme engine and admin appearance UI
Phases 3-4 of docs/website/THEMING_AND_NAV.md. Three presets, the curated font
shortlist, and /admin/appearance to drive them.

The design put the presets in theme.css as [data-theme] blocks. That does not
work: SiteContext writes --accent as an inline style on <html>, which beats any
attribute-selector block, so a preset's accent would have been painted over by
BRAND_ACCENT_COLOR while getPublic().brand.accent -- the value the Android app
themes itself from -- reported the other one.

Presets now live in server/src/config/themePresets.js. themeResolve.js layers
:root <- preset <- custom per field into a token map, getPublic() returns it as
`theme`, and the client writes it onto <html>. One authority for the merge, and
brand.accent is by construction the accent the site paints. theme.css's :root is
untouched, so an instance with no row gets no theme block and renders as today.

Also: presets carry the full 15-token palette (eight would have left Fantasy
with blue-grey borders); the option catalog is served from
GET /settings/theme/options so the form cannot offer what the server rejects;
validation is strict on write and forgiving on read; and the Discord bot now
fetches the effective accent instead of its boot-time env copy.

Fixes a Phase 0 bug in passing: settings/nav.controller.js imported the logger
factory rather than calling it, so a DB fault would have thrown a TypeError
inside the catch instead of returning 500.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 19:16:23 -05:00
0a2ccafff6 Merge pull request 'feat(theming): settings-store, nav merge util and radius tokens (phases 0-2)' (#121) from feat/theming-nav-phase-0-2 into edge
Reviewed-on: #121
2026-08-07 23:25:25 +00:00
ec0036ce6d feat(theming): settings-store, nav merge util and radius tokens
Phases 0-2 of docs/website/THEMING_AND_NAV.md. Groundwork only: no admin UI,
no consumer wiring, and an instance that never touches the new settings keys
renders exactly as it does today.

Phase 0 - settings store:
- settingsDb.remove() and DELETE /api/v1/admin/settings/:key, the "reset to
  default" primitive. Defaults for these keys live in BRAND_* env, theme.css
  and the hardcoded NAV arrays, so reset has to delete the row rather than
  store a copy of the default. Allowlisted to the five theming/nav keys plus
  hero_layout_draft, admin-only, idempotent.
- GET /api/v1/settings/nav behind requireAuth with no role gate. AdminLayout
  renders for editors and moderators and PlayerPortalLayout for players, and
  none of them can read GET /admin/settings, so without this their nav
  override would silently never apply.
- A fifth router group for it: /public is anonymous, /admin/settings is
  adminOnly, /player is self-scoped data. This is configuration that needs a
  login.
- parseJsonSetting() in utils/settingsJson.js. settings.value is TEXT, so
  every JSON key arrives as a string; malformed or wrong-shaped reads as
  absent, never as an error and never half-applied.
- theme_visual / brand_assets / nav_public join PUBLIC_KEYS; nav_admin and
  nav_player deliberately do not.

Phase 1 - client/src/lib/navOverrides.js, the pure merge util. Presentation
only: it can set label/order/hidden and (grouped navs) group, and nothing
else. It cannot introduce a `to`, cannot touch roles/feature, and hidden:false
cannot un-hide anything - the existing filters run afterward, unchanged, and
remain the boundary.

Phase 2 - promoted 23 border-radius literals in theme.css to four tokens at
today's values (14x8px, 4x999px, 4x10px, 1x12px). The 7px/6px editor chrome
and the two 50% circles stay literal. --shadow-card and --panel-grad were
already tokens.

Tests: 16 new server tests, 20 new client tests. The route-manifest guard now
also asserts /settings/** sits behind requireAuth. Swagger and both route
artifacts regenerated.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 18:15:29 -05:00
d765280e28 Merge pull request 'docs(readme): say how to get a sidecar before explaining how it is used' (#120) from docs/installer-first-setup into main
Some checks failed
sync-project-tree / sync (push) Successful in 9s
Build container images / build (push) Successful in 59s
SonarQube / analysis (push) Failing after 1m56s
Build container images / deploy (push) Successful in 49s
Reviewed-on: #120
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-07 21:31:17 +00:00
03534c8db1 docs(readme): say how to get a sidecar before explaining how it is used
All checks were successful
PR Checks / bot-install (pull_request) Successful in 22s
PR Checks / server-tests (pull_request) Successful in 1m57s
PR Checks / client-build (pull_request) Successful in 9m6s
The shard integration section documented the contract in detail but never
told an admin where the base URL, WS URL, protocol version and token come
from. They come from the installer, which prints them at the end of a run.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 16:05:57 -05:00
5103b74a9d Merge pull request 'feat(shard)!: Protocol 3.0 cutover — visibility framework, spawn atlas, marketplace' (#118) from edge into main
All checks were successful
sync-project-tree / sync (push) Successful in 16s
Build container images / build (push) Successful in 1m12s
Build container images / deploy (push) Successful in 39s
SonarQube / analysis (push) Successful in 3m58s
Reviewed-on: #118
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-01 07:19:31 +00:00
c91fd128bf Merge pull request 'fix(shard): answer with the instance name when the shard is unnamed' (#119) from fix/ruleset-shard-name into edge
All checks were successful
PR Checks / bot-install (pull_request) Successful in 27s
PR Checks / client-build (pull_request) Successful in 35s
PR Checks / server-tests (pull_request) Successful in 1m43s
Reviewed-on: #119
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-01 06:04:19 +00:00
01a559792c fix(shard): answer with the instance name when the shard is unnamed
ServUO ships Server.cfg with `Name=My Shard`. An operator who never edited it
publishes that verbatim, so the rules page read "My Shard" under a header
carrying the real name. That value is the shard saying *unnamed* rather than
naming anything, so the site now answers with its own.

`settings.getInstanceName()` resolves `site_title || BRAND_NAME` — the same
resolution `getPublic().brand.name` already uses, so an install that set only
the site title can never show two different names on two pages. Bare
`brand.name` would have been wrong for exactly that case.

Substituted at INGEST rather than on read: world.ruleset is also broadcast
live, and the same object is handed to the SSE fan-out, so a read-time fix
would be undone by the next reconnect's frame. Matched case- and
padding-insensitively but only as a whole value, so a shard genuinely called
"My Shard Reborn" keeps its name.

Fixes a second ruleset writer found on the way: uoLinkSocket.backfill() called
shardState.setRuleset directly instead of going through the dispatcher as
ingestEach does, so the boot/reconnect snapshot silently skipped this
normalization. The two arrival orders have to produce the same stored frame.

Also renders a placeholder row on an unscored leaderboard — the instance name
with an em dash where a score goes, deliberately not shaped like an entry (no
medal, no bar) because a placeholder that looked like a real standing would be
a fabricated one. Presentation only; the API still sends an empty `top`.

Verified live against the shard + sidecar: rules page and leaderboards on web
and Android both correct.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bP
2026-08-01 00:58:21 -05:00
e50fab241f Merge pull request 'feat(shard)!: declare wire protocol 3' (#117) from chore/protocol-3-cutover into edge
All checks were successful
PR Checks / bot-install (pull_request) Successful in 18s
PR Checks / client-build (pull_request) Successful in 25s
PR Checks / server-tests (pull_request) Successful in 1m34s
Reviewed-on: #117
2026-07-30 03:02:20 +00:00
779a304173 feat(shard)!: declare wire protocol 3
The site's declared version is the admin-set uo_link_config.protocol column, so
the sidecar's PROTOCOL_VERSION 2 -> 3 bump has to be matched here or every REST
call 409s and uoLinkSocket closes the WS on the ws.hello mismatch. Five places
carry the number and all five move together: the column default, the model's
DEFAULT_PROTOCOL (what a site with nothing saved yet declares), the two
`config.protocol || 1` fallbacks in uoLinkClient/uoLinkSocket -- unreachable
today, but an unset value quietly sending 1 is exactly the confusing 409 the
version check exists to prevent -- the admin form's initial value, and the
documented env default.

The boot migration is the only subtle part. schema.sql is re-run on EVERY boot,
and `protocol` is admin-editable, so a bare UPDATE would silently un-pin an
operator who had deliberately pinned an older sidecar in Admin -> Shard. It is
therefore gated on a marker row in `settings`, written after the UPDATE: the
first boot on this build migrates, every later boot is a no-op. `protocol < 3`
rather than `= 2` picks up an install still on the old default of 1, which could
not have been talking to a v2 sidecar anyway. A fresh install has no row to
update and just gets the marker plus the new column default.

Verified against the local MariaDB through ensureSchema (the production path):
2 -> 3 with the marker written and the column default now 3; pinned back to 2 by
hand, re-ran, and it STAYED 2 -- the one-shot property holds. 673 server tests,
47 client tests, client build green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 18:03:48 -05:00
c6c0c257dd Merge pull request 'feat(shard): the player-vendor marketplace' (#116) from feat/vendor-listing into edge
All checks were successful
PR Checks / bot-install (pull_request) Successful in 29s
PR Checks / client-build (pull_request) Successful in 35s
PR Checks / server-tests (pull_request) Successful in 1m45s
Reviewed-on: #116
2026-07-29 20:03:22 +00:00
8771a1cf6c feat(shard): the player-vendor marketplace
Protocol 3.0 §8, the website half. Ingests vendor.listing / vendor.listing.remove
into shard_vendors + shard_vendor_items, serves a searchable public API over
them, and ships /site/market and /site/market/vendors/:serial.

Three things the pages have to say out loud, all consequences of how the data is
gathered:

- The prices are NOT live. The shard sweeps vendors round-robin, so a shop can be
  a full cycle behind. The banner is driven by the OLDEST vendor row, not the
  newest — the one stale shop is the one that wastes somebody's trip.
- A shop can be truncated. `total` exceeding `count` means the shop holds more
  than the shard publishes per frame; the vendor page says "showing 250 of 3,104"
  rather than presenting a partial shop as complete.
- An item may have no name. On a shard with no cliloc table the honest render is
  the item id, never an invented label.

## The pre-wired visibility rules, re-checked

Part A pre-wired market.ownerName and market.location before the frame existed,
and the sibling rule it pre-wired for leaderboards (`characterName`) turned out
to be INERT because projectValue matches literal JSON keys. Both market rules
were checked against the real frame this time:

- `ownerName` is a real key. Kept.
- `location` is a real key ONLY because the frame nests it. Flat map/x/y/region
  would have made the rule match nothing — the same failure, one part later. It
  is nested on the wire and on the read model so one rule hides the facet, the
  coordinates, the region and the house together; five flat keys would be five
  rules that drift apart.
- `ownerSerial` was ADDED. An admin who hides the owner's name and leaves a
  serial that the leaderboards and guild boards resolve back to that same name
  has not hidden anything.

Tests assert all three bite, on the stored read model AND on the raw frame —
the market's SSE stream is off by default but an admin can turn it on, and a rule
that worked on only one path is exactly the leak §3.6.1 records.

## Notable

- **No payload column on shard_vendors**, unlike shard_points_boards next door.
  The board's top-N is a fixed-size list read whole; here the items ARE the
  searchable rows, so they are normalized and nothing is left worth duplicating.
- **display_name is denormalized at ingest** (literal name preferred over the
  cliloc — a player set it, so it is more specific). Resolving at query time
  would put the cliloc table on the hot path and make search-by-name impossible.
  Because the shard's diff sweep will not re-send an unchanged shop just because
  the site learned what its items are called, a cliloc import now triggers a bulk
  re-resolution — 50 ms per thousand rows, never throws.
- **updated_at is written explicitly** on every upsert. MariaDB does not fire ON
  UPDATE CURRENT_TIMESTAMP when every column is written back unchanged, and a
  shop re-published identically is still freshly confirmed — without this the
  staleness banner would age a perfectly current shop forever.
- **LIKE wildcards in `q` are escaped.** `%` and `_` are LIKE metacharacters, not
  SQL ones, so parameterization does not neutralize them: `?q=%` would otherwise
  match every listing on the shard.
- **Rate-limited** (60/min/IP), the only limited public read. Every other public
  GET is an indexed lookup of bounded size; this is a LIKE scan plus a COUNT over
  the largest shard_* table, anonymous by default.
- Reconnect backfill pages /market, bounded by MARKET_SNAPSHOT_MAX = 5000 and
  stopping on a short page as well as on `total`, so a concurrent sweep shrinking
  the index cannot spin the walk.

## How it was tested

673 server tests pass (27 new). Client builds clean; swagger-output.json,
routes.manifest.json and routes.guards.json regenerated.

Verified full-stack against the live MariaDB and a real shard, not only units:

- 27 real vendors / 1,040 listings swept off the ServUO tree, through the Rust
  sidecar, into the site — names resolving through the cliloc table ("longsword",
  "katana"), real facets and regions in the filters.
- `?q=sword` 682, `?q=%` and `?q=_` **0** (the escape), map/region/price/sort
  filters, paging, and the vendor detail route.
- Visibility live: fields gated to staff vanish for an anonymous caller while
  shopName and price survive; audience=player 403s; enabled=0 404s; and
  /shard/features correctly drops `market` so the nav hides it.
- Re-publishing a shop smaller leaves no orphan items; an identical re-publish
  moves updated_at.
- The limiter fires (38x200 then 32x429 on a 70-request burst).

Not covered by an automated test: the two React pages are presentational and this
repo's client suite covers pure-logic modules only. They were driven against the
live API above, but not rendered in a DOM harness.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 09:51:50 -05:00
8da658f223 Merge pull request 'feat(shard): resolve cliloc names for items and reward titles' (#115) from feat/cliloc-table into edge
Reviewed-on: #115
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-29 11:58:54 +00:00
bda031566a feat(shard): read clilocs from a source SET so shard items get names
Shards edit items and add new ones, and those carry cliloc ids no stock client
table has. Reading exactly one converted file meant an operator had to
re-export 5 MB every time they added one item — friction enough that the table
would simply go stale, which is the failure the spawn atlas was redesigned to
avoid in the first place.

So this mirrors spawnAtlasSource.readSources(): a BASE (the converted client
table) plus every operator-maintained overlay under `custom/`, all re-read on
every boot and hash-gated as a SET. Later sources win, so an overlay both adds
ids the client never had and overrides stock ones the shard re-purposed.
Adding, editing or removing any overlay counts as drift.

`custom/` is the one convention here that is ours rather than the shard's, and
deliberately so: ServUO has no server-side notion of a custom cliloc — they
live in the patched client a shard distributes, and nothing in the tree
declares them. There is nothing to discover. (An operator who does patch their
client cliloc needs no overlay: convert the patched file and the edits are in
the base.) Scale, measured on the live shard: its script tree references 16,434
cliloc ids and only 37 are absent from stock — tens against a 67k base, which
is why this is an overlay and not a second table.

The set brings back a hazard a single file did not have, and it gets the
atlas's answer. A corrupt source fails the parse loudly, but a source that has
VANISHED parses perfectly and imports a table quietly missing everything it
contributed — an unmounted volume is indistinguishable from a deliberate
deletion. So it is staged, not applied (`needsReview`), reported by both the
import and status(), and accepted with `{approve:true}`. That is a flag rather
than the atlas's approve/reject pair because the atlas stores a pending
decision SO THAT approving re-parses; here nothing is stored, so re-reading at
approval time is automatic.

Also reports a per-source breakdown (entries/added/overrode) on import and in
status, which is how an operator confirms an overlay took effect — "overrode: 0"
on a file meant to re-label stock items says it did not.

Two bugs this surfaced, both found by running a shard-style overlay rather than
by another stock-table fixture:

- displayText tidied punctuation unconditionally, so a custom
  "Runic Gateway Sigil (v2)" rendered as "(v2". Stripping leftover brackets is
  right after a placeholder is removed and wrong otherwise — the same condition
  the `%` rule already had.
- CANDIDATE_NAMES did not include `clilocs.plain`, which is the exact filename
  CLILOCS.md and the export tool's README tell operators to write. Pointing at
  the directory they were told to create failed with NO_FILE.

Verified end to end against the live MariaDB and a real server boot: base-only
import, overlay adding one id and overriding another (per-source breakdown
correct), unchanged set as a no-op, an edited overlay re-importing and
withdrawing its override, a vanished overlay refused with the table intact,
status reporting missingSources, approve applying it, and a file-path
configuration still finding overlays beside it. All three resolve correctly
through the running server: shard-added, overridden and stock. 646 server tests
pass (16 new in clilocSource.test.js, 3 new in clilocParse.test.js); swagger,
routes.manifest.json and routes.guards.json regenerated.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 06:46:12 -05:00
b61a4d6721 feat(shard): resolve cliloc names for items and reward titles
Protocol 3.0 §8.6 (docs/link/v3.md), the dependency order 5 was sequenced
behind. Items on the wire carry a LabelNumber, not a name — the bridge has
always sent it (char.profile.equipment.cliloc, reward titles as a cliloc
number in string form, and one per marketplace listing) but the site had no
table to resolve it against, so a character sheet could only render
`id 1023721` where the game renders "quarter staff".

The number was never the missing piece. The table was.

Sourced from a file the operator converts once from their own client, at a
path from the `cliloc_client_path` setting falling back to UO_CLIENT_PATH.
Nothing client-derived is committed: UO's strings are EA's, exactly as the
creature sprites are. A shard with nothing configured is fully supported —
names render as ids, as they did before.

The conversion step is not avoidable, and that is the substantive finding
here: every current client ships its cliloc files COMPRESSED (first DWORD's
high byte 0x8E, the Mythic container), and ServUO's own bundled
Ultima.StringList cannot read that either — so VendorSearch.GetItemName is
already inert on such a shard and the plugin could not supply names instead.
v3.md's original "read the client's Cliloc.enu" recommendation was therefore
not implementable as written, and its committed db/data/clilocs.json artifact
also predates the Part C corrections (no committed derived snapshots, nothing
EA-derived shipped). Replaced with the spawn-atlas pattern: parse on boot from
an operator-configured path, hash-gated, output gitignored.

- utils/clilocParse.js — pure parsers, fs-free so the suite runs in CI.
  Accepts the plain binary layout and delimited text, sniffed by header rather
  than extension. Rejects a compressed file BY NAME: without that check the
  plain parser reads it as ~19k records of negative ids and 60 KB "strings"
  before dying mid-file, and the resulting error names the wrong problem.
  displayText() drops the ~1_val~ arguments the bridge never sends.
- utils/clilocSource.js — the fs layer. hashSource reports `compressed` so the
  admin panel can flag an unconverted file WITHOUT parsing 5 MB per poll;
  otherwise pointing at a client directory reports a healthy file with pending
  drift ("ready to import") and the operator only finds out on failure.
- model/shardClilocs — refresh/status/lookup. All-or-nothing replace (DELETE,
  not TRUNCATE — TRUNCATE is DDL in MariaDB and implicitly commits). Batched
  server-side resolution behind a capped cache; never throws, because a cliloc
  lookup is decoration on a character sheet.
- Deliberately NO staged-approval flow, unlike the atlas: the atlas escalates
  facet loss because a half-copied tree and a real map change are
  indistinguishable from inside the process, whereas a partial cliloc copy
  makes the parser fail on a truncated record. The ambiguity the atlas must
  escalate is one this parser simply detects.
- No public route. The table is never served AS a table: 67k rows would dwarf
  any page using them, and the Android client consumes the same resolved JSON.

Two parser bugs found by building it, both now covered by tests: trimming a
text line before splitting ate the trailing separator on empty-text entries
and silently dropped 55,994 of 123,490 while still reporting success; and
Number('') is 0, not NaN, so a line starting with a separator imported as a
bogus cliloc 0.

Verified against the real client table (123,490 entries) and the live MariaDB:
import 663 ms, hash-gated boot no-op 14 ms, cold resolve 4.2 ms / warm 0.015 ms.
Binary and TSV imports converge on the same 67,496 rows with identical keys
(blank entries — half the table — are dropped at import). A file truncated to
half its length is refused with TRUNCATED and leaves the previous table
serving. Boot logs verified for both the import and the compressed-file
warning; neither blocks startup. All three admin routes exercised over HTTP
with a real session. 629 server tests pass; client builds clean; swagger,
routes.manifest.json and routes.guards.json regenerated.

Not covered by an automated test: the character sheet renders resolved names
in presentational React with no DOM test harness in this repo, and was not
rendered against a live linked-player profile — that needs a logged-in player
with a linked game account and a shard answering a profile RPC.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 04:21:38 -05:00
1e1a3d67c3 Merge pull request 'feat(shard): ingest points.board and publish the leaderboards' (#114) from feat/points-board into edge
Reviewed-on: #114
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-29 07:52:52 +00:00
26094459ae feat(shard): ingest points.board and publish the leaderboards
Protocol 3.0 §7 (docs/link/v3.md). The shard publishes ~25 points/loyalty
leaderboards — Queen's Loyalty, Void Pool, the nine city loyalties, Clean Up
Britannia — and the site renders them, plus each character's own standings on
their sheet.

Server
  - shard_points_boards: one row per system, keyed by the shard's PointsType
    name. The top-N list stays inside `payload` — a fixed-size list read whole,
    exactly like shard_governors.candidates. Normalizing into an entries table
    buys nothing until something needs a per-character reverse lookup, and a
    character's own standings already ride inside char.profile.
  - shardIngest routes points.board to upsertPointsBoard and deliberately does
    NOT log it: this is board state like guild.update, and the shard emits a
    frame every time anyone's score moves a top ten.
  - uoLinkSocket backfills /points through snapshot() with ingestEach rather
    than a replace*: there is no points.remove and the system set is fixed, so
    upserting IS the reconciliation, and a system the operator later excludes
    keeps its last-known board rather than vanishing.
  - GET /public/shard/points and /points/:system behind
    requireFeature('leaderboards'), both projected per §3.6.1. :system is
    constrained to an identifier before any query runs; 404 for a system never
    published, distinct from a published board nobody has scored in (200, empty
    top).

The leaderboards field rule now keys on `name`, not `characterName`
  Part A pre-wired FEATURES.leaderboards.fields = { characterName: ... }, but
  projectValue matches on the LITERAL JSON key and the wire key is `name`. As
  written the rule was inert: an admin tightening character names would have got
  no enforcement and no error — precisely the failure §3.6.1 records for the
  flattened `ownerAcct` spelling. Fixed, with a test that fails if it is renamed
  back, and the admin panel's FIELD_LABEL carries the meaning instead.

Client
  - routes/public/Leaderboards.jsx at /site/leaderboards. A points.board frame
    describes ONE system, so live frames merge over the fetched set by system
    key rather than replacing it wholesale the way the ruleset does. Filter
    matches board name, system key, or any ranked player — the last is what
    makes it useful ("where do I appear?").
  - A "Loyalty & Points" section in CharacterSheet.jsx, one edit serving both
    PlayerCharacter and AdminCharacter.
  - Both treat maxPoints: 0 as UNCAPPED and both fall back to humanising the
    system key when nameString is null. Neither is defensive padding: on a real
    shard uncapped and cliloc-only names are the majority case.

Verified end to end against the local MariaDB, the Rust sidecar, and the real
ServUO shard: backfill from /points, live SSE delivery (a board absent from the
initial fetch appearing without a reload, and an existing one updating in
place), REST reflecting the overwrite, and the gate at every rung — 200 by
default with names, names stripped but points kept at fieldRules name=staff, 403
plus dropped from /features at audience=staff, 404 when disabled. Page rendered
clean, no console errors beyond the pre-existing React Router v7 warnings.

605 server tests pass; routes.manifest.json, routes.guards.json and the OpenAPI
spec regenerated.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 21:04:44 -05:00
bfa1db58c4 Merge pull request 'feat(atlas): serve the spawn atlas and give operators a panel for it' (#113) from feat/spawn-atlas-api into edge
Reviewed-on: #113
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-29 00:53:21 +00:00
7c769ea8fd feat(atlas): serve the spawn atlas and give operators a panel for it
Protocol 3.0 order 3 (Part C), second of two website PRs. #112 built the data
pipeline; this makes it reachable — six public routes, five admin ones, two
public pages and an admin panel. Still website-only: no plugin, no sidecar, no
new event kinds, no wire change.

The API sits at /api/v1/public/atlas, not under /public/shard. Nothing here
touches the sidecar, so the pages stay complete while the shard is down, and a
/shard prefix would imply a dependency the atlas does not have. Unlike /shard/*
it IS site-mode gated, like /posts and /wiki: a bestiary is site content.

Every route carries requireFeature('atlas') and projects its response. The atlas
feature declares no sensitive fields, so the projection is a no-op today — the
call is there because v3.md 3.6.1's rule is that the FIRST field needing a gate
should be covered by construction rather than by a retrofit.

Two bugs the UI surfaced, both fixed here:

Respawn delays were stored in the wrong unit, sometimes. XmlSpawner writes
MinDelay/MaxDelay in minutes and switches to seconds only when a delay does not
divide into whole minutes, flagging it per record with DelayInSec. A `5` means
five minutes on one spawner and five seconds on the next, both plausible, and
the pipeline stored the raw number. 170 of 6,455 stock spawners are second
flagged. The parser normalises to seconds; the API and UI carry seconds.

That exposed the hash gate as a trap. "Has the tree changed?" is the wrong
question on its own: an install whose maps never change would have kept serving
the old readings forever, because the only thing compared was the tree.
PARSER_VERSION is now stored beside the source hashes and a mismatch counts as
drift, so any future parse correction lands on the next boot.

Also renamed the detail route's spawn-point array to `spawners` — it was
`points`, which is the COUNT on the search route, so one key meant a number in
one place and an array in the other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bP
2026-07-28 19:51:22 -05:00
f3d084e046 Merge pull request 'feat(atlas): derive a spawn atlas from the shard tree on every boot' (#112) from feat/spawn-atlas-parse into edge
Reviewed-on: #112
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-28 21:51:28 +00:00
a4ef9d676d Merge remote-tracking branch 'origin/edge' into feat/spawn-atlas-parse 2026-07-28 16:45:56 -05:00
2801ec8f4d refactor(atlas): derive the atlas from the shard's tree on every boot
Replaces the committed-artifact design from the first commit. Two problems with
it, both raised in review:

**Facets are not a fixed list.** The first pass carried a hardcoded table of the
six stock UO facets to reconcile the spelling drift between sources. That is
wrong: a shard may add facets, replace them outright, or rename them when its
maps are updated, and a built-in list quietly mishandles all three. Nothing in
the atlas names a facet any more. The facet set is discovered from the tree —
spawn records and region definitions are the authority — and the loose spellings
in Data/Locations are matched against it by key and prefix. Custom facets get
identical treatment; the tests use `Sosaria` and `Underdark` precisely so a
stock-facet assumption cannot creep back in.

**A snapshot goes stale.** Maps change over a server's life, so a build-once
artifact silently drifts from the world players actually see. The tree is now
the single source of truth and the atlas is re-derived on every boot.

## What that changed

- **The committed artifact is gone** — 1.41 MB of generated JSON removed, along
  with `scripts/buildSpawnAtlas.js` and the whole encode/decode seam it needed
  (`encodePoint`/`readPoint`, the tuple encoding, the omitted-defaults scheme and
  their round-trip tests). Nothing to keep in sync, nothing to go stale.
- **NEW `src/utils/spawnAtlasSource.js`** — the only thing that touches a ServUO
  tree; shared by the boot path and the CLI. Parsers stay pure and fs-free.
- **NEW `src/model/shardAtlas/`** — `.db.js` (the one-transaction replace) and
  `.model.js` (the refresh decision).
- **`scripts/importSpawnAtlas.js`** is now a thin CLI over the model:
  `--servuo`, `--force`, `--approve`, `--reject`, `--status`. `atlas:build` is
  gone; `atlas:import` remains.
- Path comes from the `spawn_atlas_servuo_path` admin setting, falling back to
  `SERVUO_PATH`. The setting wins, matching how the rest of the shard
  integration is admin-managed rather than env-configured.

## Two contracts on the boot path

**It never blocks startup.** No path, an unreadable mount, a malformed file, a
database error — every one is caught and logged, and the site comes up serving
whatever atlas it already had. Verified by booting the real server with no path,
a broken path, and a good path.

**A facet disappearing is never applied automatically.** Losing a facet is the
signature of a half-copied or mid-update tree as much as of a real map change,
and boot cannot tell them apart. The refresh is staged in `shard_atlas_pending`
for an admin to approve or reject, and startup continues regardless. Additions
and every other change apply immediately, since none of them can destroy
something an operator would miss.

Only the decision is stored, not the parsed world: a few KB of source hashes and
the facet diff. Approving re-parses, so what gets applied matches the tree at
approval time rather than at boot. A rejection is remembered against those exact
hashes, so a declined refresh does not re-prompt on every restart — changing the
tree changes the hashes and asks again.

Hash-gated, so the common case (restart, maps unchanged) reads and hashes the
tree (~120 ms) and writes nothing. A real change costs a ~400 ms parse.

The admin approve/reject UI is part of the second PR, with the rest of the
routes and pages. Until then the CLI covers it.

## Verification

- **564 server tests pass**, 28 new in `spawnAtlas.source.test.js` covering the
  custom-facet build, the spelling reconciliation, hash gating, and every branch
  of the refresh decision — including that `refreshOnBoot` survives a database
  that throws on every call.
- End-to-end against the local MariaDB and the real ServUO tree: 6,455 points,
  800 creatures, 23,927 point/type rows, 387 regions, 558 landmarks, 25 altars,
  83.2% of points resolved to a place name.
- The facet gate exercised against a real tree copy with `malas.xml` removed:
  staged rather than applied, atlas untouched with all 293 Malas points intact,
  reject then stays quiet on re-run, approve applies and drops the facet.
- Booted the real server under all three source conditions; none blocked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bP
2026-07-28 16:41:33 -05:00
353cce9f26 feat(atlas): parse a ServUO tree into a committed spawn atlas artifact
Protocol 3.0 order 3 (Part C), first of two website PRs. This half is the data
pipeline only — parsers, the build/import CLI, and the tables. No routes and no
client, so nothing is user-visible yet; the API and pages follow in PR 2.

Part C is website-only: no plugin, no sidecar, no new event kinds, no wire
change.

## Parsing

`src/utils/spawnAtlasParse.js` is pure and fs-free so CI covers it with no
ServUO tree. Zero new dependencies — `Regions.xml` genuinely nests, so it gets a
small hand-rolled subset tokenizer rather than a new XML package. The 10.5 MB of
`Spawns/*.xml` never touches it: those records are flat and get a streaming
regex sweep instead.

The high-value transform is point-in-rect placement — highest region priority
wins, ties break to the smaller rect, then a nearest-landmark fallback within
200 tiles, else "Wilderness". That is what turns "lizardman at 5411,1234" into
"Despise, Felucca", and it resolves 83.2% of points (5,369 of 6,455).

Three things the real data forced, none of which were in the design:

- **Only 6 facets, not 13.** `Eodon.xml`, `GravewaterLake.xml` and the other
  named-area files carry TerMur/Trammel points, so the facet comes from each
  record's own `<Map>` and the artifact shards 6 ways.
- **Facet names disagree across sources.** `Data/Locations/*.xml` spells them
  `Ter Mur` and `Tokuno Islands`; `<Map>` and `<Facet name>` say `TerMur` and
  `Tokuno`. Unreconciled this is silent — the landmark fallback simply never
  fires on those facets and every unregioned spawn there reads "Wilderness".
- **Spawn type tokens carry XmlSpawner directives**: `Fairy,{RND,4,8}`,
  `alchemist/z/-50`, `Agralem/Name/Agralem`. Taken literally these invent
  creatures that do not exist AND split real ones in two, since `Fairy` and
  `Fairy,{RND,4,8}` slug apart. 71 of 845 entries were affected; stripping at
  the first `/` or `,` leaves 800 clean ones.

## Artifact

`npm run atlas:build -- --servuo <path>` writes `db/data/spawnAtlas.*.json`:
6 facet shards + a compact index + a small indented `meta`. 1.41 MB committed,
down from 4.40 MB by dropping `facet` per record, omitting defaulted fields, and
tuple-encoding the ~24,000 type entries. `encodePoint()` and the importer's
`readPoint()` are exact inverses and are round-tripped in tests.

Display spelling is chosen deterministically (most common, ties to the
capitalised form) because the spawn files are inconsistent about case and the
name would otherwise depend on file read order — a spurious diff on every
unrelated rebuild.

## Import

`npm run atlas:import` needs no ServUO tree, which is the whole reason build and
import are separate: the container has the artifact but not the tree. It
reloads all six tables in one transaction (DELETE, not TRUNCATE, which is DDL
and would implicitly commit), so a failed import leaves the previous atlas
intact.

## No artwork, by design

The repo ships no creature art and no extraction tooling. Sprites live in the
operator's own client `.mul`/`.uop` files and are theirs, not ours to
redistribute. `shard_spawn_creatures.art` is nullable and NULL on every fresh
import; an operator who wants art extracts it themselves, drops it under
`server/uploads/atlas/` (already gitignored) and maps slugs in a gitignored
`spawnAtlas.art.json`. Text-only is the normal, fully supported state.

## Verification

- **544 server tests pass**, 57 new across `spawnAtlas.parse.test.js` (the
  `:OBJ=` split, directive stripping, nested-region priority inheritance,
  half-open rects, the facet reconciliation, tokenizer edge cases) and
  `spawnAtlas.build.test.js` (aggregation, deterministic naming, and the
  encode/decode round trip).
- Built and imported for real against the local MariaDB and the ServUO tree at
  `C:\Users\colby\Desktop\ServUO`: 6,455 points, 800 creatures, 23,927
  point/type rows, 387 regions, 558 landmarks, 25 champion altars.
- "Where does a lizardman spawn?" answers Shrines / Isamu-Jima / Yew across
  Felucca, Trammel and Tokuno.

No routes changed, so the OpenAPI spec and route manifest are untouched.

---

- [x] AI-assisted: written with **Claude Code** (Claude Opus 5), reviewed before opening.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bP
2026-07-28 16:07:30 -05:00
7b98f1a778 Merge pull request 'feat(shard): ingest world.ruleset and publish it at /site/rules' (#111) from feat/shard-ruleset into edge
Reviewed-on: #111
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-28 20:50:35 +00:00
61d6bfaca2 feat(shard): ingest world.ruleset and publish it at /site/rules
Protocol 3.0 §5 (docs/link/v3.md). The shard publishes its own ruleset —
expansion, which optional systems are on, skill/stat caps, account and house
limits, champion scroll rules, the save/restart schedule — and the site renders
it, so the rules page cannot drift from how the shard actually plays.

Server
  - shard_ruleset: a singleton table (id = 1) holding the whole frame in
    `payload`, with `rev` and `expansion` hoisted. Nothing is normalized out:
    the frame is a flat description of config read as one page, and splitting it
    into columns would mean a schema change every time the shard grows a block.
  - shardIngest routes world.ruleset to setRuleset and deliberately does NOT
    log it — the shard re-emits the whole ruleset on every sidecar connect, so
    logging would append a duplicate row per reconnect, and server.hello already
    marks each of those.
  - uoLinkSocket backfills GET /ruleset explicitly rather than via snapshot(),
    which asserts an array; this covers the order where the sidecar was already
    up and holding the ruleset when we reconnected.
  - GET /public/shard/ruleset behind requireFeature('ruleset') and projected,
    per §3.6.1's rule that a shard read which doesn't project is a bug. `null`
    means the shard has never published one — a real answer, distinct from a
    published ruleset, and the page says so.

Client
  - routes/public/Rules.jsx at /site/rules, live via world.ruleset (a frame is a
    complete ruleset, not a delta, so the newest one wins outright). Caps are
    rendered from tenths — 7000 is 700.0, and showing the raw number would
    mislead. A systems key this build doesn't know still renders, humanised, so
    a newer plugin can't go invisible against an older client.
  - Nav entry gated on the `ruleset` feature, so it hides rather than 403s.

Verified end to end against the local MariaDB and a sidecar fed by a fake shard:
backfill snapshot, live SSE delivery of a changed ruleset, REST reflecting the
overwrite, an empty /feed (not logged), and the gate — 200 by default, 403 at
audience=staff (and dropped from /features so nav hides it), 404 when disabled.
Page rendered clean at all breakpoints checked, no console errors.

497 server tests pass; routes.manifest.json, routes.guards.json and the OpenAPI
spec regenerated.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 14:35:24 -05:00
6b1396dd2f Merge pull request 'fix(shard): enforce visibility on the REST reads that bypassed it' (#110) from fix/shard-visibility-rest-projection into edge
Reviewed-on: #110
2026-07-28 15:58:29 +00:00
f30ea66fce fix(shard): enforce visibility on the REST reads that bypassed it
Protocol 3.0 Part A follow-up, found by the live five-rung smoke test.

Part A implemented the visibility framework correctly on the SSE path
and on /guilds + /governors, but the remaining public REST reads never
called into it. The result was that one event was projected live and
served verbatim from history:

  * GET /public/shard/feed returned the stored payload as-is, so
    actor.acct and actor.webId were readable ANONYMOUSLY for every
    logged kind - player.death, player.murdered, mob.killed,
    quest.complete, skill.gain, fame/karma.change, mob.login/logout,
    guild.join. Broader than the guild-leader leak Part A set out to
    close, since it covers every player rather than board holders.

  * GET /public/shard/idoc returned ownerAcct - the house owner's game
    account - to anonymous callers.

  * The `houses` field rules (owner/price -> staff) were dead config:
    neither getIdoc nor getHouses projected, so an admin could set them
    in the panel and nothing happened.

  * /feed filtered on PUBLIC_KINDS, a module-load constant derived from
    the compiled DEFAULTS, so live audience changes did not reach it.
    With `guilds` moved to staff, /guilds 403'd while /feed happily
    served guild.join to anonymous.

Four fixes, all at the root rather than per-route:

1. Rule 1 now matches a field's MEANING, not one spelling. The wire
   nests actors (leader.acct) but the read models flatten them
   (shapeHouse -> ownerAcct, shapeGuild -> leaderWebId), and an
   exact-key check missed every flattened one. isLockedField() locks a
   key that is or ends in acct/webId, case-insensitively, so it fails
   closed for shapes not yet written. The admin PUT rejects those
   spellings too - `ownerAcct` is no longer configurable.

2. visibleKinds(level, config) resolves readable kinds from the LIVE
   config; getFeed uses it and projects each row against its own kind's
   feature. Deliberately independent of the `stream` flag, which governs
   SSE fan-out only - so market history stays readable with its firehose
   off. This makes the set a superset of PUBLIC_KINDS by exactly the two
   vendor kinds.

3. getIdoc/getHouses/getChamps/getPresence project, so every shard
   surface honours the same config.

4. shardEvents.db.list treats an EMPTY kinds array as "serve nothing".
   It previously fell through to the unfiltered query, so a fully-gated
   config would have dumped the whole event log, staff audit included.

Also fixes a bug introduced while wiring this up: projectValue recursed
into any object, so a Date column came back as {}. It now walks arrays
and plain objects only. The unit tests used JSON fixtures and could not
have caught it - the live /idoc read did.

Verified live against MariaDB + a stub sidecar, all five rungs: 13
routes x 5 rungs, defaults reproducing pre-v3 access exactly, zero
acct/webId below admin on any read, unmapped kinds (staff.command,
cheat.detect, login.attempt) reaching only admin on SSE, and audience /
enabled / stream changes taking effect live on an already-open stream.

Tests: 487 server (+9). Swagger regenerated; route manifest unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 10:49:55 -05:00
cd56af3f12 Merge pull request 'feat(shard): admin-configurable visibility for every shard surface' (#109) from feat/shard-visibility-framework into edge
Reviewed-on: #109
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-28 15:08:12 +00:00
f3450686e0 feat(shard): admin-configurable visibility for every shard surface
Protocol 3.0 Part A. Replaces the static PUBLIC_KINDS allowlist - which
was the entire public/admin boundary - with per-feature, per-field
audience control an admin owns from Admin -> Shard Visibility.

Closes a live leak. BridgeJson.Actor() writes acct and webId;
shapeGuild() returned the stored payload verbatim; GET
/api/v1/public/shard/guilds is anonymous. Guild leaders' game account
names and website user ids were readable by anyone, and the same path
existed for governors. Both are now projected.

The ladder is anonymous < logged_in < player < staff < admin, each rung
implying the ones below. Staff satisfy `player` without a linked account
(as /player/* already does); `editor` is a content role and gets no
shard privilege, since mapping it to staff would silently widen what
editors see.

Two invariants are code, not configuration, and both reject rather than
silently ignore:

  1. acct/webId are admin-only always - not configurable, discarded on
     read as well as rejected on write.
  2. A kind absent from KIND_FEATURE never reaches anyone below admin.
     Fail closed, so a shard emitting a new event degrades to staff-only
     rather than to public.

Enforcement is three points over one config: requireFeature() on routes
(404 disabled, 403 out-of-rung) plus field projection; per-connection
filtering on SSE, where a subscriber's rung is resolved once at subscribe
time and frozen so a long-open stream cannot gain privilege; and
/public/shard/features so the SPA hides links it cannot follow.

PUBLIC_KINDS still exists and is still exported (/feed filtering,
notificationStreams) but is now derived from the kind map, so the two
can no longer drift. Defaults reproduce pre-3.0 behavior exactly - a
test pins the derived set against the old allowlist.

Also fixes an SSE resource leak found while testing: a client dropped
because its write threw was removed from the bucket but its keepalive
interval was never cleared, firing forever on a dead socket. Both paths
now go through one drop().

Tests: 478 server (33 new across shardVisibility + shardBroadcast),
43 client. Route manifest and OpenAPI spec regenerated.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 10:04:48 -05:00
a3407ae654 Merge pull request 'feat(auth): honor and establish trusted devices on the SSO login paths' (#108) from feat/sso-trusted-device into main
All checks were successful
sync-project-tree / sync (push) Successful in -15s
Build container images / build (push) Successful in 1m37s
Build container images / deploy (push) Successful in 37s
SonarQube / analysis (push) Successful in 3m21s
Reviewed-on: #108
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-28 06:12:57 +00:00
620781b7bc feat(auth): honor and establish trusted devices on the SSO login paths
All checks were successful
PR Checks / bot-install (pull_request) Successful in 19s
PR Checks / client-build (pull_request) Successful in 25s
PR Checks / server-tests (pull_request) Successful in 9m21s
"Trust this device" did nothing for anyone who signs in with Google or Discord.
sso.controller went straight from needsTotp(user) to staging a pending-TOTP
challenge and never consulted resolveTrustedDevice, so an SSO user was asked for
a code on EVERY sign-in no matter how many times they had ticked the box — and
POST /auth/sso/totp accepted only `code`, so that step could not establish a
trust either. The password paths (web + native) were unaffected and already
worked; this closes the gap for SSO, on the website AND in the Android app.

Server:
- finishLogin and finishMobileLogin now run the same trusted-device check as
  auth.controller.login, via one shared helper: honor a trust that belongs to
  THIS user, stamp last_used_at, log auth.login.trusted_device. A store error
  falls through to the challenge — fail closed to asking for the code.
- POST /auth/sso/totp gains optional trustDevice + deviceName, sets the rg_trust
  cookie, and mirrors the password path's { trustLimitReached, devices } response
  at the cap (the sign-in still completes). Recovery codes stay password-only.

Android coverage, without leaking a secret into a URL:
- The app opens SSO in a Custom Tab, which shares the system browser's cookie
  jar, so the rg_trust cookie set on that TOTP form is presented back on the next
  app sign-in. That alone makes native SSO skip the code. Passing the app's token
  into the start URL was rejected — it would put a 256-bit secret in query
  strings, Referer headers and access logs.
- To also cover the app's NATIVE password login, ticking the box sets
  mobile_auth_sessions.trust_device (a boolean; never the token), and
  /auth/mobile/sso/exchange mints a platform:'mobile' trust and returns
  { trustToken }. Minting there keeps the raw token on an authenticated
  app→server call, out of the deep link and out of the bridge row. Best-effort:
  at the cap the response just omits it rather than failing a good sign-in.

Client: the trust checkbox is no longer hidden on the SSO second step, on both
the admin and player login screens. On the mobile bridge the deep-link redirect
takes priority over the cap prompt — the sign-in succeeded and the link is
single-use, so stalling there would strand the app.

Tests: 8 new cases in server/test/ssoTrustedDevice.test.js (verified to fail
against the pre-fix controller). Full suites green — server 445, client 43 —
and routes.manifest.json is a zero-line diff: no URL moved, only +2 handlers on
/auth/sso/totp in routes.guards.json for the two new validators. Swagger
regenerated. Verified live against the running server and real MariaDB: the TOTP
step issues rg_trust and persists the row, a subsequent SSO callback carrying it
skips the code, and an invalid trust is still challenged.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 01:01:12 -05:00
f6611231c4 Merge pull request 'fix(shard): stop an undecryptable uo-link token 500ing every live-shard route' (#107) from fix/uolink-client-throw-and-sitemode-gate into main
All checks were successful
sync-project-tree / sync (push) Successful in 11s
Build container images / build (push) Successful in 1m8s
Build container images / deploy (push) Successful in 35s
SonarQube / analysis (push) Successful in 2m32s
Reviewed-on: #107
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-28 05:33:33 +00:00
a6fd5659c4 fix(shard): stop an undecryptable uo-link token 500ing every live-shard route
All checks were successful
PR Checks / bot-install (pull_request) Successful in 28s
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 44s
`uoLinkClient.call()` resolved the uo-link config OUTSIDE its try/catch.
resolveConfig() decrypts the stored auth token, and secretBox.decrypt throws
when the ciphertext can't be authenticated — SECRET_ENC_KEY rotated, or a DB
dump restored into an environment keyed differently. That throw escaped the
client entirely, breaking its documented "never throws / always returns
{ ok, data, status }" contract and turning a misconfiguration into a 500 on
every route that does a live sidecar round-trip:

  GET /admin/uo-link/config
  GET /{admin,player}/shard/char/:serial
  GET /{admin,player}/shard/roster/:account
  GET /{admin,player}/shard/vendors/:account

Found by a live smoke test of all 200 routes at every access level. Public
shard routes were unaffected because they read the DB via getSafe(), which
never decrypts.

Move resolveConfig() inside the try so the failure returns the standard
{ ok: false } shape, and log it at ERROR with a distinct message: a wrong key
previously looked identical to "the shard is offline", with no clue why.
Those routes now degrade to 503, and GET /admin/uo-link/config returns 200
again — it is the screen an admin needs to re-enter the token and recover, so
having it 500 locked them out of the fix.

Also gate the admin Dashboard's site-mode toggle. PUT /admin/site-mode is
adminOnly, but the button rendered for every staff role, and toggle() had a
try/finally with no catch — so an editor clicking it got an unhandled promise
rejection and zero UI feedback. Gate the control on role === 'admin' (the rule
AdminLayout already documents: never show a non-admin a control that would 403)
and surface a message if the call is refused anyway.

Adds server/test/uoLinkClient.test.js, which fails against the unfixed client.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 00:22:40 -05:00
068844bfd9 Merge pull request 'refactor(server): split public, player and residual auth into capability routers (PR 5)' (#106) from refactor/router-split-5 into main
All checks were successful
sync-project-tree / sync (push) Successful in 12s
Build container images / build (push) Successful in 1m2s
Build container images / deploy (push) Successful in 39s
SonarQube / analysis (push) Successful in 2m35s
Reviewed-on: #106
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-28 02:04:57 +00:00
565a7d2c20 refactor(server): split public, player and residual auth into capability routers (PR 5)
All checks were successful
PR Checks / bot-install (pull_request) Successful in 17s
PR Checks / client-build (pull_request) Successful in 25s
PR Checks / server-tests (pull_request) Successful in 9m20s
The last split PR of docs/website/API_V2_PLAN.md § Phase 2. public.routes.js,
player.routes.js and auth.routes.js are deleted; each group is now a directory
whose index.js owns the group gate and the mount table and declares no routes.
Every one of the 200 manifest routes is now in a capability router.

  public/  posts (2) wiki (4) pages (2) shard (12) site (4, group root)
  player/  account (8) shard (8) appeals (4), behind noindex + requireAuth
  auth/    login (2) register (1) invite (2) password (3) session (2, root)

No URL moves. All four gates zero-diff: routes.manifest.json (200 public + 2
internal), routes.guards.json, swagger-output.json (198 operations), and
docs/website/api-route-inventory.json was already in sync. 434 tests green.

Notes on the non-mechanical parts:

- public/index.js and auth/index.js carry no group gate, deliberately, and say
  so. The public surface is anonymous by contract (logged-out SPA, Discord bot,
  Android ShardStreamClient on /public/shard/stream); /auth is where a caller
  becomes authenticated. player/index.js gates on requireAuth only, never
  requireRole('player') — staff are a superset of players.
- GET /auth/me has a mount-order dependency: use('/me', meRouter) matches the
  bare /me, so the request runs meRouter's noindex + requireAuth and falls
  through. session.router.js must stay mounted last. Verified by the
  counterfactual — mounting it first still 401s but drops X-Robots-Tag, which
  no manifest or guards file can see.
- loginGuards moved to auth/loginGuards.js (frozen) rather than being copied
  into the three routers that spread it; sso.routes.js drops its duplicate.
- The :param shadowing check was re-run in dispatch order against the built
  stack: 86 routes, 64 literal, none shadowed. /public/wiki/{categories,tags}
  ahead of /:slug is the only ordering-sensitive pair.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 20:52:14 -05:00
3fcc64ab96 Merge pull request 'refactor(server): split admin shard, uo-link, email, discord-bot, settings and dashboard into capability routers (PR 4)' (#105) from refactor/admin-router-split-4 into main
All checks were successful
sync-project-tree / sync (push) Successful in -11s
Build container images / build (push) Successful in 1m21s
Build container images / deploy (push) Successful in 40s
SonarQube / analysis (push) Successful in 2m47s
Reviewed-on: #105
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-28 01:33:45 +00:00
8fd0d82580 refactor(server): split admin shard, uo-link, email, discord-bot, settings and dashboard into capability routers
All checks were successful
PR Checks / bot-install (pull_request) Successful in 17s
PR Checks / client-build (pull_request) Successful in 24s
PR Checks / server-tests (pull_request) Successful in 9m21s
PR 4 of the in-place admin router split (docs/website/API_V2_PLAN.md § Phase 2),
and the last admin one: it moves the entire residual 33 and DELETES
admin.routes.js. Every one of the 110 admin routes is now declared in a
capability router. No URL, gate or handler changes.

  shard.router.js      (16)  /admin/shard
  uoLink.router.js     ( 5)  /admin/uo-link
  email.router.js      ( 6)  /admin/email
  discordBot.router.js ( 2)  /admin/discord-bot
  settings.router.js   ( 2)  /admin/settings
  dashboard.router.js  ( 2)  GET /dashboard + PUT /site-mode, at the group root
  admin.routes.js            deleted, was 33

No gate moved to router level. Every adminOnly in the residual file was
per-route, and modAccess on /shard must stay per-route because half that router
must not have it — which keeps the per-route handler count intact, the one
number routes.guards.json can actually check.

/shard is the first prefix where two tiers share one router: 7 self-service
account-linking routes (no extra gate, served by the same player/shard
controller handlers, tagged `Admin · Account`) alongside 9 in-game staff ops on
modAccess. Prefix ownership beats tag grouping — splitting by tag would put two
routers under one prefix for no gain. The tag mismatch stays; retagging is a
real spec diff and belongs in a PR about tags.

dashboard.router.js is the one router mounted at the group root rather than a
prefix: GET /dashboard and PUT /site-mode share no path segment. That is safe
only because the file declares no router-level middleware — a bare use(gate) in
a root-mounted router would run for every request passing through toward
another mount. The file carries a comment saying so.

Acceptance — all four gates zero-diff:
  routes.manifest.json    unchanged (200 public + 2 internal)
  routes.guards.json      unchanged (no route lost or gained a gate)
  swagger-output.json     unchanged (198 operations)
  api-route-inventory.json already in sync
plus 434 server tests green.

Verified separately, because no gate can catch it: introspecting the built
stack, all 59 literal admin paths still dispatch to their own layer — nothing
is captured first by a /:param sibling. The manifest sorts its entries, so
declaration order is invisible to it.

Also repoints the comments that referenced admin.routes.js by name
(botActivity/moderation controllers, the town-crier cap mirror in
announceJobs.logic.js) and generalizes the "the path is on the line after
router.get(" rationale in routeManifest.js, README.md and pr-checks.yml, which
was never about that one file.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 20:02:28 -05:00
812b895507 Merge pull request 'refactor(server): split admin posts, uploads, wiki and pages into capability routers (PR 3)' (#104) from refactor/admin-router-split-3 into main
All checks were successful
sync-project-tree / sync (push) Successful in 13s
Build container images / build (push) Successful in 51s
Build container images / deploy (push) Successful in 35s
SonarQube / analysis (push) Successful in 2m39s
Reviewed-on: #104
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-28 00:36:27 +00:00
00ad16858a refactor(server): split admin posts, uploads, wiki and pages into capability routers
All checks were successful
PR Checks / bot-install (pull_request) Successful in 16s
PR Checks / server-tests (pull_request) Successful in 37s
PR Checks / client-build (pull_request) Successful in 9m15s
PR 3 of the in-place admin router split (docs/website/API_V2_PLAN.md § Phase 2).
Moves the content tier out of the residual admin.routes.js into one router file
per capability, each mounted at the prefix it already owned. No URL, gate or
handler changes.

  posts.router.js     ( 9)  /admin/posts
  uploads.router.js   ( 1)  /admin/uploads
  wiki.router.js      (14)  /admin/wiki
  pages.router.js     ( 7)  /admin/pages
  admin.routes.js     (33)  residual, was 64

All four capabilities are editor tier, so no gate moved: the shared
`noindex, isLoggedIn, staffOnly` in admin/index.js is their whole gate.

The multer config moved to admin/imageUpload.js because the two routes that
share it (POST /posts/upload and POST /uploads) now live in different files;
duplicating a mimetype allowlist is how the two copies drift. It stays in
admin/ because UPLOAD_DIR is resolved relative to __dirname.

Acceptance — all four gates zero-diff:
  routes.manifest.json    unchanged (200 public + 2 internal)
  routes.guards.json      unchanged (no route lost or gained a gate)
  swagger-output.json     unchanged (198 operations)
  api-route-inventory.json already in sync
plus 434 server tests green.

Verified separately, because no gate can catch it: the wiki router's literal
/categories and /tags paths still precede /:slug in declaration order. The
manifest sorts its entries, so a reordering there would be invisible.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 19:24:07 -05:00
493843241e Merge pull request 'refactor(server): split admin moderation, bot-activity and activity into capability routers (PR 2)' (#103) from refactor/admin-router-split-2 into main
All checks were successful
sync-project-tree / sync (push) Successful in 16s
Build container images / build (push) Successful in 57s
Build container images / deploy (push) Successful in 35s
SonarQube / analysis (push) Successful in 2m40s
Reviewed-on: #103
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-28 00:12:02 +00:00
bd53a0b8a4 refactor(server): split admin moderation, bot-activity and activity into capability routers
All checks were successful
PR Checks / bot-install (pull_request) Successful in 24s
PR Checks / client-build (pull_request) Successful in 32s
PR Checks / server-tests (pull_request) Successful in 46s
PR 2 of the domain split (docs/website/API_V2_PLAN.md § Phase 2). Carves 18 more
routes out of admin.routes.js into one router file per business capability,
in place, with every URL unchanged:

  moderation.router.js   (15)  /admin/moderation    modAccess at router level
  botActivity.router.js   (2)  /admin/bot-activity  adminOnly per route
  activity.router.js      (1)  /admin/activity      staff-wide, no extra gate

The residual admin.routes.js drops from 82 routes to 64.

Moderation was already gated by a prefix mount (adminRouter.use('/moderation',
modAccess)), so moderationRouter.use(modAccess) is the exact equivalent now that
the router is mounted at a prefix. Bot-activity's adminOnly was per-route and is
deliberately kept per-route: that is what holds the per-route handler count in
routes.guards.json, the only signal that would catch a dropped gate, since
requireRole(...) returns an anonymous arrow and never appears by name.

/activity gets its own file rather than waiting for dashboard.router.js in PR 4
— it is the staff audit log, a different capability from the dashboard's stats
overview and from the botScore middleware's in-memory ban state.

Acceptance:
  - routes.manifest.json  zero-diff (200 public + 2 internal)
  - routes.guards.json    zero-diff
  - swagger-output.json   zero-diff (198 operations)
  - api-route-inventory.json already in sync
  - 434 server tests green
  - role gates verified identical to main by reading the requireRole role sets
    off the live Express stack for every moved route plus untouched controls

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 18:53:15 -05:00
0e11e28cca Merge pull request 'build(swagger): normalize and sort generated OpenAPI path keys' (#101) from build/swagger-normalize-paths into main
All checks were successful
sync-project-tree / sync (push) Successful in 12s
Build container images / build (push) Successful in 53s
Build container images / deploy (push) Successful in 37s
SonarQube / analysis (push) Successful in 2m38s
Reviewed-on: #101
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-27 21:00:57 +00:00
f7c98b8ba3 Merge pull request 'refactor(server): split admin users, account, invites and auth providers into capability routers' (#102) from refactor/admin-router-split-1 into build/swagger-normalize-paths
All checks were successful
PR Checks / bot-install (pull_request) Successful in 21s
PR Checks / client-build (pull_request) Successful in 28s
PR Checks / server-tests (pull_request) Successful in 41s
Reviewed-on: #102
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-27 21:00:03 +00:00
8ad892725f refactor(server): split admin users, account, invites and auth providers into capability routers
First of the five domain-split PRs in docs/website/API_V2_PLAN.md § Phase 2. Pure
mechanical re-wiring: routes move between files, no handler, gate, validator or
annotation changes, and not one URL moves.

New src/router/v1/admin/index.js owns the two things the group shares — the
`noindex, isLoggedIn, staffOnly` gate and the mount table — and declares no routes
itself. The gate sits ahead of every mount so a capability router extracted in a
later PR cannot silently ship without it. Four capability routers mount at the
prefix they already owned inside the monolith:

  account.router.js        6 routes  -> /admin/account   (self-service, no adminOnly)
  users.router.js         15 routes  -> /admin/users     (adminOnly, router-level)
  invites.router.js        3 routes  -> /admin/invites   (adminOnly, per-route)
  authProviders.router.js  4 routes  -> /admin/auth      (adminOnly, per-route)

admin.routes.js keeps the other 82 (6+15+3+4+82 = the 110 inventoried admin
routes) and is mounted last at the group root; none of the four prefixes appears
in it, so nothing depends on mount ordering. It disappears when PR 5 lands.

Handlers still live in admin.controller.js and usersShard.controller.js — this
re-wires routes, not logic. `adminOnly` moves with the routes that use it, and
`usersRouter.use(adminOnly)` is exactly equivalent to the old
`adminRouter.use('/users', adminOnly)` now that the router is mounted at /users.

All three generated gates are zero-diff:

  routes.manifest.json    unchanged (200 public + 2 internal)
  routes.guards.json      unchanged — no route lost or gained a gate
  swagger-output.json     unchanged, byte-for-byte

The spec staying byte-identical depends on the path normalization landed in the
preceding commit; without it the four collection routes would have documented as
/api/v1/admin/{users,invites,account}/ with a trailing slash.

Server tests green (434/434).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 15:54:00 -05:00
1a61cd1638 build(swagger): normalize and sort generated OpenAPI path keys
All checks were successful
PR Checks / bot-install (pull_request) Successful in 15s
PR Checks / client-build (pull_request) Successful in 23s
PR Checks / server-tests (pull_request) Successful in 9m16s
Prepares the committed spec for the admin router domain split
(docs/website/API_V2_PLAN.md § Phase 2) by post-processing swagger-autogen's
output in swagger/swagger.js. No route, handler or annotation changes.

Trailing slashes are stripped from path keys. swagger-autogen builds a path by
string-concatenating the mount prefix with the route argument, so a capability
router mounted at /users whose collection route is router.get('/') documents as
/api/v1/admin/users/ — advertising a URL no client calls while dropping the one
the SPA, the Android app and the Discord bot all do. Express is indifferent
(non-strict routing treats the two as one route, and routes.manifest.json records
the canonical slash-less form), but the published spec is a contract. The split
creates one of these per capability router, so it is fixed once here rather than
by contorting the route declarations in every router file.

Path keys are also sorted. The generator emits them in router-traversal order, so
moving a route between files rewrites most of this ~5k-line committed artifact
even when the API is provably unchanged, burying the one line a reviewer needs to
see. OpenAPI attaches no meaning to path order, and scripts/routeManifest.js
already sorts for the same reason.

Verified inert: the regenerated spec is byte-for-byte the sorted form of the
previously committed one — same 198 operations, zero added or removed, and no
trailing-slash keys (there were none to strip yet; the guard is for the split).
A collision after normalization throws rather than silently dropping an
operation. Server tests green (434/434).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 15:49:31 -05:00
0dc5af0d8b Merge pull request 'feat(security): soak the tightened CSP on report-only, with a same-origin sink' (#100) from feature/csp-report-only into main
All checks were successful
sync-project-tree / sync (push) Successful in 12s
Build container images / build (push) Successful in 1m20s
Build container images / deploy (push) Successful in 42s
SonarQube / analysis (push) Successful in 2m44s
Reviewed-on: #100
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-27 20:31:31 +00:00
9b74999610 feat(security): soak the tightened CSP on report-only, with a same-origin sink
All checks were successful
PR Checks / bot-install (pull_request) Successful in 16s
PR Checks / server-tests (pull_request) Successful in 37s
PR Checks / client-build (pull_request) Successful in 9m15s
Phase 1 of docs/website/API_V2_PLAN.md. The tightened policy ships on
Content-Security-Policy-Report-Only alongside the unchanged enforced one for a
release; a follow-up PR flips it after the soak comes back clean.

The plan expected a two-directive delta. It is one. `form-action 'self'` was
described as absent because it is not in the directives object in app.js — but
the middleware runs with `useDefaults: true` and helmet's defaults already
supply it, so the header served in production has carried it all along. Caught
by capturing the live header from the running app instead of reading the config.
It is now written out explicitly in config/csp.js regardless: a security
directive should not depend on a third-party library's default surviving its
next major version. The enforced header's contents do not change at all, and a
test pins it verbatim.

So the whole behavioural delta is `frame-ancestors 'self'` -> `'none'`. That is
still the directive most worth soaking: a frame-ancestors report is generated by
the browser of whoever framed the site, which is the only way to find out that
something legitimately embeds us before an enforcing policy breaks it.

The policies move to config/csp.js, with the report-only one derived by spread
from the enforced one so the two cannot drift and the object reads as a diff.

`report-to` needs somewhere to point, so this adds POST /api/csp-report --
same-origin on purpose, since reports describe attacks against this site and
should not go to a third-party collector. It is mounted outside /api/v1 next to
/api/health: the browser learns the path from the policy header, never from a
client build, so it is not versioned client contract.

It is necessarily unauthenticated -- browsers send reports with no session, and
gating it would silence exactly the anonymous visitors worth hearing about -- so
it is bounded on every axis:

  * both wire formats, since report-uri (Firefox/Safari) sends hyphenated keys
    in application/csp-report and report-to (Chrome) sends camelCase envelopes
    in application/reports+json; handling one silently drops half the browsers,
  * report-to also needs the Reporting-Endpoints response header or it is inert,
  * 16 KB body cap, per-IP rate limit, fixed field allowlist, every logged field
    truncated (script-sample is attacker-influenced and can carry a whole inline
    script),
  * always 204, even for malformed input: a 4xx would reach the global error
    handler, which logs the offending body -- turning an open endpoint into a
    log-flood primitive.

Nothing is persisted; reports go to the `csp` log tag.

routes.manifest.json moves 199 -> 200, which is the freeze from PR 0 working as
designed: the one new URL is visible as a reviewed +1 rather than slipping
through. Swagger regenerated to match.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 15:19:26 -05:00
49b70ee04d Merge pull request 'chore(server): freeze the URL surface with a generated route manifest (PR 0)' (#99) from chore/route-manifest into main
All checks were successful
sync-project-tree / sync (push) Successful in 16s
SonarQube / analysis (push) Successful in 2m28s
Build container images / build (push) Successful in 1m15s
Build container images / deploy (push) Successful in 41s
Reviewed-on: #99
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-27 20:07:29 +00:00
1079b3fc05 chore(server): freeze the URL surface with a generated route manifest
All checks were successful
PR Checks / bot-install (pull_request) Successful in 17s
PR Checks / client-build (pull_request) Successful in 25s
PR Checks / server-tests (pull_request) Successful in 9m40s
PR 0 of the router domain split (docs/website/API_V2_PLAN.md § Phase 2). The
split promises that admin.routes.js can be carved into one router file per
business capability without moving a single URL. That promise has to be proved
by a diff, not asserted in review — this lands the tool that proves it, with no
router file moved.

scripts/routeManifest.js walks the live Express stack (runtime introspection,
not source parsing: route paths in admin.routes.js sit on the line *after*
`adminRouter.get(`, which defeats greps) and writes a sorted { method, path }
list to routes.manifest.json. It reproduces the frozen baseline in
docs/website/api-route-inventory.json byte-for-byte — 199 public routes plus 2
on the internal listener — so the freeze is confirmed accurate, not just
claimed.

Scope is /api/** and /.well-known/** plus the internal app. The SPA catch-all,
/uploads and /brand are filesystem-conditional static mounts, so including them
would make the output depend on whether CI had built the client. Static mounts
are not API contract.

Also emits routes.guards.json — a review aid, not a contract: per route, the
handler count and the *named* middleware on its mount chain. Router-level
`use(noindex, isLoggedIn, staffOnly)` gates never appear in an individual
route's own stack, so an extracted capability router that forgot to re-apply
one would otherwise publish authenticated endpoints silently. Names are a hint
only (requireRole(...) returns an anonymous arrow), but a vanished requireAuth
is unambiguous — and the test suite asserts every /admin/** and /player/**
route still carries it.

The plan's optional unauthenticated-status snapshot was tried and dropped, as
it allowed: against the dead-port mariadb pool the tests use, the sweep sits on
the pool's acquire timeout and had not finished after two minutes. A flaky
two-minute gate is worse than none; the requireAuth assertion covers the same
regression deterministically.

CI runs `npm run routes:manifest -- --check` on every PR, so a URL change can
only merge by deliberately committing the new manifest.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 14:55:38 -05:00
cbe54fcc91 Merge pull request 'ci(docs): auto-sync PROJECT_TREE.md to the docs repo on push to main' (#98) from chore/sync-project-tree-ci into main
All checks were successful
sync-project-tree / sync (push) Successful in -6s
Build container images / build (push) Successful in 1m17s
Build container images / deploy (push) Successful in 37s
SonarQube / analysis (push) Successful in 2m46s
Reviewed-on: #98
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 21:31:48 +00:00
9f9bcc6f6e ci(docs): auto-sync PROJECT_TREE.md to the docs repo on push to main
All checks were successful
PR Checks / bot-install (pull_request) Successful in 15s
PR Checks / client-build (pull_request) Successful in 24s
PR Checks / server-tests (pull_request) Successful in 9m33s
Add a sync-project-tree workflow that regenerates this repo's tracked-file
tree and opens (or force-updates) a PR against RunicGateway/docs whenever the
layout on main changes. Never writes to the docs repo's main directly. Reuses
the existing REGISTRY_USER / REGISTRY_TOKEN secrets. Tree rendering lives in
.gitea/scripts/gen_tree.py (deterministic, dirs-first ordering).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 16:21:22 -05:00
ebfae765d9 Merge pull request 'fix(moderation): windowValue must not fall back to the 30d total on a null column' (#97) from fix/window-value-null-column into main
All checks were successful
Build container images / build (push) Successful in 54s
Build container images / deploy (push) Successful in 36s
SonarQube / analysis (push) Successful in 2m27s
Reviewed-on: #97
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 18:15:46 +00:00
c075ab981c fix(moderation): windowValue must not fall back to the 30d total on a null column
All checks were successful
PR Checks / bot-install (pull_request) Successful in 15s
PR Checks / client-build (pull_request) Successful in 24s
PR Checks / server-tests (pull_request) Successful in 39s
windowValue mapped only the 24h/7d keys and used `?? row.d30` as the fallback:

    const col = { '24h': row.d1, '7d': row.d7 }[key] ?? row.d30

so a null d1/d7 (which the function is documented to tolerate) returned the
30-day count instead of 0, inflating the 24h/7d moderation tiles. It happens to
be masked today because `SUM(created_at >= ?)` nulls d1/d7/d30 only in unison,
but the contract is wrong and the existing test used an all-null row that hid it.

Map all three window keys explicitly so each reads its own column and a null
coerces to 0 via `Number(col) || 0`. Add a regression test with a null narrow
column and a non-null d30.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 13:14:02 -05:00
bcdba4ce0a Merge pull request 'fix(admin): restore digit match in discordId route validation' (#96) from fix/discord-id-validation-regex into main
All checks were successful
Build container images / build (push) Successful in 1m9s
Build container images / deploy (push) Successful in 46s
SonarQube / analysis (push) Successful in 2m36s
Reviewed-on: #96
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 18:11:42 +00:00
e08c0c9736 fix(admin): restore digit match in discordId route validation
All checks were successful
PR Checks / bot-install (pull_request) Successful in 17s
PR Checks / client-build (pull_request) Successful in 25s
PR Checks / server-tests (pull_request) Successful in 9m30s
The `:discordId` param validator on the five admin moderation routes used
`/^d{1,32}$/`, which matches 1-32 literal `d` characters instead of digits.
A real numeric Discord snowflake failed validation, so every
`/moderation/user/:discordId*` endpoint returned a 400 for valid input.

The backslash was dropped in a prior code-smell cleanup (12d50fd) that
intended `[0-9]` -> `\d`. Restore `\d` so the regex matches digits again.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 13:01:12 -05:00
5fe7032567 Merge pull request 'fix(ntfy): publish ntfy host port so the external reverse proxy can reach it' (#95) from fix/ntfy-published-port into main
All checks were successful
Build container images / build (push) Successful in 57s
Build container images / deploy (push) Successful in 34s
SonarQube / analysis (push) Successful in 2m31s
Reviewed-on: #95
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 09:08:13 +00:00
4151f7d44e fix(ntfy): publish ntfy host port so the external reverse proxy can reach it
All checks were successful
PR Checks / bot-install (pull_request) Successful in 18s
PR Checks / client-build (pull_request) Successful in 30s
PR Checks / server-tests (pull_request) Successful in 9m29s
The ntfy service was configured with no published host port, on the
assumption that the public reverse proxy shares the compose network and
can dial ntfy:80 directly. It does not — Pangolin runs outside the
compose network and reaches every service through a published host port
(exactly why `app` publishes 3000). With no published port there was
nothing for the notification subdomain to forward to, so push delivery
could never work in production.

Publish container :80 on a host port (NTFY_HOST_PORT, default 2586,
binds 0.0.0.0 like `app`) and correct the now-inaccurate comments in
docker-compose.yml and ntfy/server.yml. Document NTFY_HOST_PORT in
.env.example. No code change — deploy config only.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 03:57:32 -05:00
4f1a4902e8 Merge pull request 'fix(player): open the player self-service surface to staff' (#94) from fix/staff-player-self-service into main
All checks were successful
SonarQube / analysis (push) Successful in 2m40s
Build container images / build (push) Successful in 22s
Build container images / deploy (push) Successful in 38s
Reviewed-on: #94
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 08:36:13 +00:00
14dfc122ba fix(player): open the player self-service surface to staff
All checks were successful
PR Checks / bot-install (pull_request) Successful in 18s
PR Checks / client-build (pull_request) Successful in 27s
PR Checks / server-tests (pull_request) Successful in 9m28s
Staff are a superset of players — every player ability plus their staff
tools on top — but the /player/* group ran requireRole('player'), so a
signed-in admin/editor/moderator got 403 on their own linked game
accounts (e.g. GET /player/shard/accounts). On the Android client this
hid "My characters" and greyed the personal notification streams for
staff accounts, even when they had linked characters.

Drop the role gate: the group is now requireAuth-only. Every handler is
already self-scoped to the caller by req.user.id (with the pre-existing
isAdmin bypass still letting a genuine admin read any character), so this
only ever widens access to the caller's OWN data. Staff also reach the
identical self-scoped handlers under /admin/shard/* (same controller).

- player.routes.js: requireRole('player') -> requireAuth; corrected the
  five stale "Player role required" 403 descriptions and regenerated
  swagger-output.json.
- New test/playerRouteAccess.test.js mounts the router and asserts
  player/admin/editor/moderator all reach the handler, anon still 401s,
  and a disabled account still 403s. Suite: 420 pass.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 02:17:29 -05:00
271 changed files with 32373 additions and 20349 deletions

View File

@@ -10,5 +10,8 @@ uploads
server/logs server/logs
logs logs
*.log *.log
# Installed modules are mounted at runtime, never baked into the image. Without
# this a module in the builder's working tree would ship inside every image.
modules
.DS_Store .DS_Store
Thumbs.db Thumbs.db

View File

@@ -33,8 +33,8 @@ LOG_FILE=app.log
# BRAND_NAME / BRAND_CONTACT_EMAIL. # BRAND_NAME / BRAND_CONTACT_EMAIL.
BRAND_NAME=Runic Gateway BRAND_NAME=Runic Gateway
BRAND_SHORT_NAME=Runic Gateway BRAND_SHORT_NAME=Runic Gateway
BRAND_TAGLINE=an independent private Ultima Online shard BRAND_TAGLINE=an independent game community
BRAND_DESCRIPTION=Runic Gateway — an independent private Ultima Online shard. News, screenshots, guides, and community notes. BRAND_DESCRIPTION=Runic Gateway — an independent game community. News, screenshots, guides, and community notes.
BRAND_CONTACT_EMAIL= BRAND_CONTACT_EMAIL=
BRAND_URL= BRAND_URL=
# Accent color — drives the web theme's --accent and the Discord embed color. # Accent color — drives the web theme's --accent and the Discord embed color.
@@ -107,17 +107,18 @@ CLIENT_ORIGIN=http://localhost:5173
BOT_INTERNAL_URL=http://bot:4100 BOT_INTERNAL_URL=http://bot:4100
BOT_INTERNAL_KEY=change-me-to-a-long-random-string BOT_INTERNAL_KEY=change-me-to-a-long-random-string
# uo-link sidecar — the HTTP + WebSocket bridge to the ServUO game server. The # ─── Installed modules ───
# website ingests its live event feed and proxies its read queries/commands # A module is a directory on the modules volume (see MODULES_DIR in
# (shard status, online players, player-vendor sales, IDOC houses, character # server/.env.example); everything about a specific game lives in one, and core
# sheets, account linking, town-crier). In production the sidecar + shard run on # knows nothing about any of them. A module may read its own env vars, and they
# a DIFFERENT host from the website, so both URLs are configurable. The # belong here because Compose passes this file to the container.
# shared-secret auth token is NOT an env var — it is entered in the admin panel #
# (Shard page) and stored encrypted in the DB (same pattern as the Discord bot # RunicGateway/Module-uo, for example, reads UOLINK_BASE_URL / UOLINK_WS_URL /
# token). These URLs are just defaults; the admin can override them at runtime. # UOLINK_PROTOCOL as the defaults for its connection to a uo-link sidecar, and
UOLINK_BASE_URL=http://127.0.0.1:8080 # TOWNCRIER_DURATION_SEC for its news leg. Its README documents them; they are
UOLINK_WS_URL=ws://127.0.0.1:8080/ws # left out here rather than half-copied, because a copy of another repo's
UOLINK_PROTOCOL=1 # settings is a copy that goes stale silently. With no module installed, none of
# this applies and the site runs as core.
# ─── Push notifications (M7) — self-hosted ntfy UnifiedPush relay ─── # ─── Push notifications (M7) — self-hosted ntfy UnifiedPush relay ───
# The `ntfy` compose service and the backend's push fan-out (opt-in notifications # The `ntfy` compose service and the backend's push fan-out (opt-in notifications
@@ -131,6 +132,12 @@ UOLINK_PROTOCOL=1
# register endpoints on a different host than NTFY_BASE_URL. # register endpoints on a different host than NTFY_BASE_URL.
# NTFY_PUBLISH_TOKEN Optional. The content-free-tickle design needs NO token; # NTFY_PUBLISH_TOKEN Optional. The content-free-tickle design needs NO token;
# set one only to require auth on backend→ntfy publishes. # set one only to require auth on backend→ntfy publishes.
# NTFY_HOST_PORT Host port the ntfy container publishes :80 on (default
# 2586). The public reverse proxy forwards the notification
# subdomain to host:NTFY_HOST_PORT — required because the
# proxy lives outside the compose network and cannot reach
# ntfy any other way. Change only on a host-port conflict.
NTFY_BASE_URL=https://ntfy.example.com NTFY_BASE_URL=https://ntfy.example.com
# NTFY_ALLOWED_ORIGINS=https://ntfy.example.com # NTFY_ALLOWED_ORIGINS=https://ntfy.example.com
# NTFY_PUBLISH_TOKEN= # NTFY_PUBLISH_TOKEN=
# NTFY_HOST_PORT=2586

View File

@@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""Render an ASCII tree of tracked files, read from stdin (one path per line).
Used by the `sync-project-tree` workflow to regenerate this repo's PROJECT_TREE.md
snapshot in the RunicGateway/docs repo. Feed it `git ls-files`:
git ls-files | python3 .gitea/scripts/gen_tree.py <root-label>
Deterministic ordering: directories before files, each group sorted
case-insensitively with the raw name as a tiebreak. Output uses the classic
`tree(1)` box-drawing style so the result is stable across runs and platforms.
"""
import sys
def build(paths):
root = {}
for p in paths:
p = p.strip().replace("\\", "/")
if not p:
continue
node = root
for part in p.split("/"):
node = node.setdefault(part, {})
return root
def render(node, prefix, lines):
entries = list(node.items())
# directories (non-empty children dict) before files, then case-insensitive name
entries.sort(key=lambda kv: (0 if kv[1] else 1, kv[0].lower(), kv[0]))
for i, (name, child) in enumerate(entries):
last = i == len(entries) - 1
branch = "└── " if last else "├── "
suffix = "/" if child else ""
lines.append(f"{prefix}{branch}{name}{suffix}")
if child:
render(child, prefix + (" " if last else ""), lines)
def main():
try:
sys.stdout.reconfigure(encoding="utf-8", newline="\n")
except AttributeError:
pass
root_label = sys.argv[1] if len(sys.argv) > 1 else "."
tree = build(sys.stdin.read().splitlines())
lines = [f"{root_label}/"]
render(tree, "", lines)
sys.stdout.write("\n".join(lines) + "\n")
if __name__ == "__main__":
main()

View File

@@ -1,8 +1,15 @@
# Gate every pull request into `main` on a fast, DB-free check suite so a broken # Gate every pull request into `main` or `edge` on a fast, DB-free check suite so
# build or failing test can't reach the deployable branch. Complements # a broken build or failing test can't reach either integration branch. Complements
# build-images.yml, which runs only AFTER merge (on push to main) to publish # build-images.yml, which runs only AFTER merge (on push to main) to publish
# images — this one runs BEFORE merge. # images — this one runs BEFORE merge.
# #
# `edge` is listed as well as `main` because long workstreams land phase by phase
# on `edge` and reach `main` as a single cutover (the module system, protocol v3).
# With `branches: [main]` alone, every one of those phase PRs merges with NO checks
# at all and the entire workstream runs blind until the cutover — which is exactly
# what happened to the nine Android M12 phase PRs in that repo. A branch that
# accumulates work for weeks needs the gate more than `main` does, not less.
#
# Enforcement (one-time, in the Gitea UI): # Enforcement (one-time, in the Gitea UI):
# Repository Settings → Branches → Branch Protection (rule for `main`) # Repository Settings → Branches → Branch Protection (rule for `main`)
# • Enable Status Check # • Enable Status Check
@@ -10,6 +17,10 @@
# Note: Gitea only lists a context in its dropdown after it has reported once, # Note: Gitea only lists a context in its dropdown after it has reported once,
# so let this workflow run on one PR first. The `PR Checks / *` glob matches # so let this workflow run on one PR first. The `PR Checks / *` glob matches
# without needing the dropdown. # without needing the dropdown.
# The workflow now RUNS on PRs into `edge` too, but running is not enforcing:
# blocking a red phase PR needs its own protection rule for `edge`, with the
# same `PR Checks / *` pattern. Without one the checks report and merging stays
# possible anyway.
# #
# Runner: reuses the existing self-hosted `ubuntu-latest` runner. These jobs need # Runner: reuses the existing self-hosted `ubuntu-latest` runner. These jobs need
# only Node (no Docker socket), and the server tests stub their models + point the # only Node (no Docker socket), and the server tests stub their models + point the
@@ -19,7 +30,7 @@ name: PR Checks
on: on:
pull_request: pull_request:
branches: [main] branches: [main, edge]
# A newer push to the same PR cancels the in-flight run. # A newer push to the same PR cancels the in-flight run.
concurrency: concurrency:
@@ -36,11 +47,28 @@ jobs:
node-version: 20 node-version: 20
cache: npm cache: npm
cache-dependency-path: server/package-lock.json cache-dependency-path: server/package-lock.json
- name: Check core names no module identifier
# Phase 3's acceptance criterion 1 (MODULE_API.md §5.2): core must not
# name a module's files, import them, route to them, or declare its
# symbols. Before `npm ci`, deliberately — it is plain Node over
# server/ and client/ source with no dependency of its own, so putting it
# first makes a boundary break the first thing a reviewer sees instead of
# something found under a pile of unrelated failures, and it costs
# nothing when it passes.
run: npm run check:modules
- name: Install server deps - name: Install server deps
run: npm ci --prefix server run: npm ci --prefix server
- name: Run server tests - name: Run server tests
run: npm test --prefix server run: npm test --prefix server
- name: Check the route manifest is current
# The URL surface is frozen while the routers are carved up by capability
# (docs/website/API_V2_PLAN.md § Phase 2). Regenerating from the live Express
# stack and diffing proves a "mechanical" refactor moved no URL. A PR that
# really does change one has to commit the new manifest, putting it in front
# of a reviewer instead of letting it pass silently.
run: npm run routes:manifest --prefix server -- --check
client-build: client-build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:

View File

@@ -0,0 +1,111 @@
name: sync-project-tree
# Keeps this repo's file-layout snapshot (docs/website/PROJECT_TREE.md in the
# RunicGateway/docs repo) current. On every push to `main` it regenerates the
# tree from tracked files and, if it changed, opens (or force-updates) a pull
# request against the docs repo. It never writes to the docs repo's `main`
# directly. Auth reuses the same REGISTRY_USER / REGISTRY_TOKEN secrets the
# other workflows use (the token needs repo read/write on RunicGateway/docs).
on:
push:
branches: [main]
workflow_dispatch: {}
concurrency:
group: sync-project-tree
cancel-in-progress: true
env:
GITEA_HOST: gitea.whitlocktech.com
DOCS_REPO: RunicGateway/docs
SELF_REPO: RunicGateway/website
DOCS_PATH: website/PROJECT_TREE.md
TREE_TITLE: Website
ROOT_LABEL: website
PR_BRANCH: chore/sync-website-tree
jobs:
sync:
runs-on: ubuntu-latest
steps:
- name: Check out this repo
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Ensure python3 is available
run: |
set -euo pipefail
command -v python3 >/dev/null 2>&1 || { sudo apt-get update -qq && sudo apt-get install -y -qq python3; }
- name: Render PROJECT_TREE.md from tracked files
run: |
set -euo pipefail
mkdir -p _sync
{
printf '# %s — Project Tree\n\n' "${TREE_TITLE}"
printf '> **Auto-generated.** This file is maintained by the `sync-project-tree` CI workflow in\n'
printf '> the [`%s`](https://%s/%s) repository, which\n' "${SELF_REPO}" "${GITEA_HOST}" "${SELF_REPO}"
printf '> opens a pull request here whenever the tracked file layout on `main` changes. Do not edit\n'
printf '> by hand — changes will be overwritten by the next sync.\n\n'
printf 'A snapshot of the tracked files in the repository (build output, dependencies, and other\n'
printf 'git-ignored paths are excluded).\n\n'
printf '```text\n'
git ls-files | python3 .gitea/scripts/gen_tree.py "${ROOT_LABEL}"
printf '```\n'
} > _sync/PROJECT_TREE.md
echo "----- generated ${DOCS_PATH} -----"
cat _sync/PROJECT_TREE.md
- name: Open or update the docs PR if the tree changed
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
# Secrets can carry a trailing CR/LF depending on how they were pasted;
# strip line breaks before they land in a URL or Authorization header.
CI_USER="$(printf '%s' "${REGISTRY_USER}" | tr -d '\r\n')"
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
API="https://${GITEA_HOST}/api/v1/repos/${DOCS_REPO}"
REMOTE="https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${DOCS_REPO}.git"
git clone --depth 1 "${REMOTE}" docs_repo
cd docs_repo
git config user.name "runic-docs-bot"
git config user.email "ci@whitlocktech.com"
mkdir -p "$(dirname "${DOCS_PATH}")"
cp ../_sync/PROJECT_TREE.md "${DOCS_PATH}"
git add "${DOCS_PATH}"
if git diff --cached --quiet; then
echo "PROJECT_TREE.md already up to date — nothing to sync."
exit 0
fi
SHORT_SHA="$(echo "${GITHUB_SHA:-local}" | cut -c1-7)"
git checkout -B "${PR_BRANCH}"
git commit -m "docs(tree): sync ${DOCS_PATH} from ${SELF_REPO}@${SHORT_SHA} [skip ci]"
git push --force "${REMOTE}" "HEAD:${PR_BRANCH}"
# Open a PR only if one isn't already open for this branch (a force-push
# to an existing open PR's head updates it in place).
OPEN="$(curl -sSf -H "Authorization: token ${CI_TOKEN}" \
"${API}/pulls?state=open&limit=50" \
| jq --arg b "${PR_BRANCH}" '[.[] | select(.head.ref == $b)] | length')"
if [ "${OPEN}" = "0" ]; then
curl -sSf -X POST "${API}/pulls" \
-H "Authorization: token ${CI_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg head "${PR_BRANCH}" \
--arg base "main" \
--arg title "docs(tree): sync ${DOCS_PATH}" \
--arg body "Automated project-tree sync from [\`${SELF_REPO}\`](https://${GITEA_HOST}/${SELF_REPO}), regenerated from tracked files on \`main\`. Merge once the layout looks right; the workflow will keep this branch current until then." \
'{head: $head, base: $base, title: $title, body: $body}')" \
>/dev/null
echo "Opened a new docs PR for ${PR_BRANCH}."
else
echo "Existing open docs PR for ${PR_BRANCH} was updated via force-push."
fi

25
.gitignore vendored
View File

@@ -21,6 +21,31 @@ uploads/
server/logs/ server/logs/
logs/ logs/
# Installed modules (docs/website/MODULE_SYSTEM.md). Core ships no module, so
# anything here is an operator's install or a developer's scratch copy. The
# directory itself IS tracked, via its README: docker-compose.yml bind-mounts it,
# and a missing bind-mount source is recreated by Docker as root-owned.
modules/*
!modules/README.md
# Operator-supplied spawn atlas artwork. Creature art is never committed: sprites
# are extracted from the operator's own UO client .mul/.uop files and are theirs,
# not ours to redistribute. The images live under server/uploads/atlas/, already
# ignored above; this is the slug -> file-name map pointing at them.
# See docs/website/SPAWN_ATLAS.md and db/data/spawnAtlas.art.example.json.
server/db/data/spawnAtlas.art.json
# Operator-supplied cliloc table. UO's localization strings are EA's, extracted
# from the operator's own client and converted once (docs/website/CLILOCS.md);
# the repo ships no string table, for the same reason it ships no artwork and no
# map snapshot. This covers the conventional in-repo location — the supported
# arrangement is a path OUTSIDE the repo, set from Admin → Shard.
server/db/data/cliloc*
server/db/data/clilocs.*
# The build output of tools/cliloc-export (a throwaway helper, not a package).
server/tools/cliloc-export/bin/
server/tools/cliloc-export/obj/
# reference material (extracted from the provided archives) # reference material (extracted from the provided archives)
_reference/ _reference/

View File

@@ -54,6 +54,12 @@ If you add or change an API route, regenerate the Swagger spec
(`cd server && npm run swagger`) and commit the updated (`cd server && npm run swagger`) and commit the updated
`server/swagger/swagger-output.json`. `server/swagger/swagger-output.json`.
The URL surface is also frozen by a generated manifest. If your change adds,
removes or renames a route, regenerate it (`cd server && npm run routes:manifest`)
and commit `server/routes.manifest.json` + `server/routes.guards.json` — CI fails
otherwise. A non-empty diff in `routes.manifest.json` means you changed the API
contract, so call it out in the PR description; a pure refactor must produce none.
## Branch & PR workflow ## Branch & PR workflow
1. Fork or branch from `main`. Use a descriptive branch name 1. Fork or branch from `main`. Use a descriptive branch name

View File

@@ -21,6 +21,12 @@ RUN if [ -f client/package.json ]; then \
# Persistent uploads + logs live on mounted volumes. # Persistent uploads + logs live on mounted volumes.
RUN mkdir -p /app/uploads /app/logs && chown -R node:node /app/uploads /app/logs RUN mkdir -p /app/uploads /app/logs && chown -R node:node /app/uploads /app/logs
# Installed modules are mounted in too (docker-compose.yml), and .dockerignore
# keeps any local modules/ OUT of the image — a module must never be baked in.
# The directory is still created here so a container run without the mount finds
# an empty, writable modules dir rather than no directory at all.
RUN mkdir -p /app/modules && chown node:node /app/modules
USER node USER node
EXPOSE 3000 EXPOSE 3000

272
README.md
View File

@@ -8,16 +8,19 @@
[![Security Rating](https://sonar.whitlocktech.com/api/project_badges/measure?project=runic-gateway-website&metric=security_rating&token=sqb_d3593f26ac5663cd3e666039b7038f3248e8df50)](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website) [![Security Rating](https://sonar.whitlocktech.com/api/project_badges/measure?project=runic-gateway-website&metric=security_rating&token=sqb_d3593f26ac5663cd3e666039b7038f3248e8df50)](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
[![Vulnerabilities](https://sonar.whitlocktech.com/api/project_badges/measure?project=runic-gateway-website&metric=vulnerabilities&token=sqb_d3593f26ac5663cd3e666039b7038f3248e8df50)](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website) [![Vulnerabilities](https://sonar.whitlocktech.com/api/project_badges/measure?project=runic-gateway-website&metric=vulnerabilities&token=sqb_d3593f26ac5663cd3e666039b7038f3248e8df50)](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
Public site, wiki, and protected admin panel for a private Ultima Online shard — a Public site, wiki, and protected admin panel for a game community — a full-stack app
full-stack app in one repo. Branding is instance-configurable via `BRAND_*` (see in one repo. Everything specific to a *particular* game lives in an installable
[Branding](#branding)); **UOMysticmoon** is the first instance. module, not here. Branding is instance-configurable via `BRAND_*` (see
[Branding](#branding)); **UOMysticmoon**, an Ultima Online shard, is the first
instance, and its game half is
[RunicGateway/Module-uo](https://gitea.whitlocktech.com/RunicGateway/Module-uo).
A full-stack app in one repo: A full-stack app in one repo:
- **Backend** — Node.js + Express REST API (layered `router → controller → model → db`), MariaDB, a provider-agnostic session layer (JWT cookie for web, bearer tokens for mobile, pluggable SSO). - **Backend** — Node.js + Express REST API (layered `router → controller → model → db`), MariaDB, a provider-agnostic session layer (JWT cookie for web, bearer tokens for mobile, pluggable SSO).
- **Frontend** — React + Vite single-page app (public site, wiki, and the admin panel), dark "gothic" theme (Cinzel + Georgia). - **Frontend** — React + Vite single-page app (public site, wiki, and the admin panel), dark "gothic" theme (Cinzel + Georgia).
- **Deploy** — Docker Compose (app + MariaDB) behind a reverse proxy (Pangolin, Nginx, Caddy, Traefik, …). Express serves the built SPA in production. - **Deploy** — Docker Compose (app + MariaDB) behind a reverse proxy (Pangolin, Nginx, Caddy, Traefik, …). Express serves the built SPA in production.
- **Shard link** — a live bridge to the in-game ServUO shard through the **uo-link** sidecar ([RunicGateway/link](https://gitea.whitlocktech.com/RunicGateway/link)): the site ingests a live event feed and makes server-side REST calls to show shard status, economy, staff presence, IDOCs, live activity, and per-character sheets. See [Shard integration (uo-link)](#shard-integration-uo-link). - **Modules** — the game-specific half of a site is a module dropped onto a volume: it adds routes, database tables, nav entries and whole SPA pages without this repo knowing anything about the game. See [Modules](#modules).
The design reference is [BACKEND_DESIGN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/BACKEND_DESIGN.md) (API contract, schema, security), in the [**RunicGateway/docs**](https://gitea.whitlocktech.com/RunicGateway/docs) repo — where all project documentation now lives. The design reference is [BACKEND_DESIGN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/BACKEND_DESIGN.md) (API contract, schema, security), in the [**RunicGateway/docs**](https://gitea.whitlocktech.com/RunicGateway/docs) repo — where all project documentation now lives.
@@ -37,7 +40,7 @@ The design reference is [BACKEND_DESIGN.md](https://gitea.whitlocktech.com/Runic
- [Pages & routes](#pages--routes) - [Pages & routes](#pages--routes)
- [API endpoints](#api-endpoints) - [API endpoints](#api-endpoints)
- [API documentation (Swagger)](#api-documentation-swagger) - [API documentation (Swagger)](#api-documentation-swagger)
- [Shard integration (uo-link)](#shard-integration-uo-link) - [Modules](#modules)
- [Environment variables](#environment-variables) - [Environment variables](#environment-variables)
- [Security](#security) - [Security](#security)
- [Logging](#logging) - [Logging](#logging)
@@ -48,8 +51,8 @@ The design reference is [BACKEND_DESIGN.md](https://gitea.whitlocktech.com/Runic
## Architecture ## Architecture
How the pieces fit together — the React SPA and native app talk to one Express backend How the pieces fit together — the React SPA and native app talk to one Express backend
(`router → controller → model → db`), which persists to MariaDB and bridges to the live (`router → controller → model → db`), which persists to MariaDB. Anything that knows
game world only through the **uo-link** sidecar. The shard itself is never internet-facing. what game this site is about lives in an installed module, on the right of the diagram.
```mermaid ```mermaid
flowchart TB flowchart TB
@@ -69,32 +72,29 @@ flowchart TB
subgraph backend["server/ — Express backend"] subgraph backend["server/ — Express backend"]
direction TB direction TB
mw["Middleware<br/>helmet · siteMode · noindex<br/>rateLimit · loginProtection · botScore · validate"] mw["Middleware<br/>helmet · siteMode · noindex<br/>rateLimit · loginProtection · botScore · validate"]
router["Router /api/v1<br/>auth (web · mobile · sso) · public · admin"] router["Router /api/v1<br/>auth (web · mobile · sso) · public · admin · player"]
ctrl["Controllers"] ctrl["Controllers"]
auth["Session layer (auth/)<br/>sessionService · JWT/cookie · bearer · SSO+PKCE"] auth["Session layer (auth/)<br/>sessionService · JWT/cookie · bearer · SSO+PKCE"]
model["Models (.model + .db)<br/>raw parameterized SQL — no ORM"] model["Models (.model + .db)<br/>raw parameterized SQL — no ORM"]
sse["SSE fan-out<br/>public stream (allowlist) · admin stream (sensitive)"] sse["SSE fan-out<br/>public stream (allowlist) · admin stream (sensitive)"]
loader["modules/loader.js<br/>scans the volume · mounts · registries · lifecycle"]
subgraph shardutil["Shard integration (utils/)"]
ingest["shardIngest.js<br/>WS ingest dispatcher"]
restcli["uoLinkClient.js<br/>REST client (never throws)"]
end
secret["secretBox.js<br/>AES-256-GCM secrets at rest"] secret["secretBox.js<br/>AES-256-GCM secrets at rest"]
end end
bot["bot/<br/>Discord bot"] bot["bot/<br/>Discord bot"]
end end
db[("MariaDB<br/>users · posts · wiki · settings · activity<br/>mobileSessions · authProviders · userIdentities<br/>uoLinkConfig · shard_online/economy/houses/events")] db[("MariaDB<br/>users · posts · wiki · settings · activity<br/>mobileSessions · authProviders · userIdentities<br/>installed_modules · &lt;module&gt;_*")]
%% ---------- Shard side ---------- %% ---------- Module side ----------
subgraph shardside["Game shard (never internet-facing)"] subgraph modside["modules/&lt;id&gt;/ &nbsp;— installed, not built (e.g. Module-uo)"]
direction TB direction TB
sidecar["uo-link sidecar<br/>(Rust) — the only bridge exposed"] modsrv["server/ — routers, models, schema fragment<br/>reaches core only through ctx"]
servuo["ServUO shard<br/>(C# plugin)"] modcli["client/dist/entry.js — prebuilt ESM chunk<br/>React shared via window.__rg"]
end end
game["The game<br/>whatever the module talks to<br/>(for Module-uo: a ServUO shard,<br/>via the uo-link sidecar)"]
%% ---------- Edges ---------- %% ---------- Edges ----------
browser <-->|"same-origin JSON + SSE (cookie)"| mw browser <-->|"same-origin JSON + SSE (cookie)"| mw
mobile -->|"REST (bearer access/refresh)"| mw mobile -->|"REST (bearer access/refresh)"| mw
@@ -104,40 +104,42 @@ flowchart TB
mw --> router --> ctrl mw --> router --> ctrl
ctrl --> auth ctrl --> auth
ctrl --> model ctrl --> model
ctrl --> restcli
ctrl --> sse ctrl --> sse
auth --> model auth --> model
model <--> db model <--> db
auth -. reads/writes secrets .-> secret auth -. reads/writes secrets .-> secret
restcli -. reads config/token .-> secret
ingest --> model
ingest --> sse
sse -->|"live events"| browser sse -->|"live events"| browser
bot -->|"messages"| discord bot -->|"messages"| discord
bot <--> db bot <--> db
restcli -->|"REST: /char /roster /economy /history · /link/confirm · /towncrier"| sidecar loader -->|"mounts under /api/v1/&lt;tier&gt;/&lt;prefix&gt;"| router
sidecar -->|"WebSocket live event feed (bearer + X-UOLink-Version)"| ingest loader -->|"require() + register(ctx, api)"| modsrv
servuo -->|"loopback TCP 127.0.0.1:7788<br/>newline-delimited JSON (shard dials out)"| sidecar modsrv -->|"ctx.db · ctx.push · ctx.activity …"| model
modsrv <--> game
browser -->|"&lt;script type=module&gt; injected by htmlShell"| modcli
%% ---------- Styling ---------- %% ---------- Styling ----------
classDef ext fill:#2d2233,stroke:#7a5c94,color:#e8dff0; classDef ext fill:#2d2233,stroke:#7a5c94,color:#e8dff0;
classDef store fill:#1f2d2a,stroke:#4c8c7d,color:#dff0ea; classDef store fill:#1f2d2a,stroke:#4c8c7d,color:#dff0ea;
classDef bridge fill:#2d2620,stroke:#94764c,color:#f0e6d8; classDef mod fill:#2d2620,stroke:#94764c,color:#f0e6d8;
class idp,discord ext; class idp,discord,game ext;
class db store; class db store;
class sidecar,servuo bridge; class modsrv,modcli mod;
``` ```
- **One backend, layered.** Every request flows `middleware → router → controller → model → db`. - **One backend, layered.** Every request flows `middleware → router → controller → model → db`.
Web browsers authenticate with an httpOnly JWT cookie; the native app uses short-lived bearer Web browsers authenticate with an httpOnly JWT cookie; the native app uses short-lived bearer
access tokens plus rotated refresh tokens; SSO (Google/Discord/OIDC) is link-only and PKCE-guarded. access tokens plus rotated refresh tokens; SSO (Google/Discord/OIDC) is link-only and PKCE-guarded.
All three surfaces produce the *same* session via the session layer. All three surfaces produce the *same* session via the session layer.
- **The shard is never reachable.** The ServUO shard *dials out* over loopback TCP to the uo-link - **Core knows nothing about any game.** Routes, tables, nav entries, SPA pages and push streams for
sidecar; only the sidecar is exposed, and only the backend talks to it. The REST client a specific game arrive from a module the operator installed. Core provides the seams; the module
(`uoLinkClient.js`) never throws, so the site degrades gracefully when the shard is down. fills them. See [Modules](#modules).
- **Sensitive events stay private.** Ingested game events fan out to browsers over two SSE channels - **A module that fails must never take the site down.** The loader catches failures across a
a public allowlist stream and an admin-only stream that adds staff audit / cheat / login events. module's whole lifecycle and marks that one module `startup_failed`; the site comes up with its
routes and nav absent, and the admin panel says why.
- **Sensitive events stay private.** Events fan out to browsers over two SSE channels — a public
allowlist stream and an admin-only stream that adds staff audit / cheat / login events. Which
event kinds are public is decided by the module that publishes them, and core enforces the split.
--- ---
@@ -164,12 +166,13 @@ website/
│ │ ├─ server.js bootstrap: ensure schema → seed → listen (0.0.0.0) │ │ ├─ server.js bootstrap: ensure schema → seed → listen (0.0.0.0)
│ │ ├─ app.js middleware + static SPA + routes │ │ ├─ app.js middleware + static SPA + routes
│ │ ├─ auth/ session layer: session.service · token (JWT/cookies) · session.middleware · ssoState (PKCE/CSRF) · providers/ (base · oauth2 · google · discord · genericOidc · registry) │ │ ├─ auth/ session layer: session.service · token (JWT/cookies) · session.middleware · ssoState (PKCE/CSRF) · providers/ (base · oauth2 · google · discord · genericOidc · registry)
│ │ ├─ router/v1/ auth (web · mobile · sso) / public / admin route groups │ │ ├─ router/v1/ auth (web · mobile · sso) / public / admin / player route groups
│ │ ├─ model/ users · posts · wiki · settings · activity · mobileSessions · authProviders · userIdentities (.model + .db) │ │ ├─ model/ users · posts · wiki · settings · activity · mobileSessions · authProviders · userIdentities · modules (.model + .db)
│ │ ├─ modules/ loader (scan · validate · mount) · registries (the seams) · lifecycle (boot/shutdown + reconcile)
│ │ ├─ middleware/ siteMode · noindex · rateLimit · loginProtection · botScore · validate │ │ ├─ middleware/ siteMode · noindex · rateLimit · loginProtection · botScore · validate
│ │ └─ utils/ auth (compat facade) · totp (2FA) · secretBox (AES-GCM secrets) · db (pool) · mailer · logger │ │ └─ utils/ auth (compat facade) · totp (2FA) · secretBox (AES-GCM secrets) · db (pool) · mailer · logger · htmlShell
│ ├─ db/ schema.sql + seed.js │ ├─ db/ schema.sql + seed.js
│ ├─ swagger/ swagger.js (OpenAPI generator config) + swagger-output.json (generated spec) │ ├─ swagger/ swagger.js (generator config) · swagger-output.json (generated, core only) · docsSpec.js (merges module fragments at request time)
│ └─ .env.example │ └─ .env.example
├─ client/ React + Vite SPA ├─ client/ React + Vite SPA
│ ├─ src/ │ ├─ src/
@@ -178,9 +181,11 @@ website/
│ │ ├─ routes/admin/ AdminLogin (password + TOTP + SSO buttons), AdminLayout, views/ (Dashboard, Posts, Wiki, Settings, Activity, Bot Activity, Authentication, Users, Account) + editors │ │ ├─ routes/admin/ AdminLogin (password + TOTP + SSO buttons), AdminLayout, views/ (Dashboard, Posts, Wiki, Settings, Activity, Bot Activity, Authentication, Users, Account) + editors
│ │ ├─ components/ SiteHeader, SiteFooter, layout, guards, Modal, ProviderIcon (inline SSO SVGs), … │ │ ├─ components/ SiteHeader, SiteFooter, layout, guards, Modal, ProviderIcon (inline SSO SVGs), …
│ │ ├─ contexts/ AuthContext, SiteContext │ │ ├─ contexts/ AuthContext, SiteContext
│ │ ├─ modules/ the client registry: routes · nav · slots · feature gates · window.__rg
│ │ ├─ api/client.js fetch wrapper (sends cookies) │ │ ├─ api/client.js fetch wrapper (sends cookies)
│ │ └─ styles/theme.css design tokens │ │ └─ styles/theme.css design tokens
│ └─ public/assets/img/ hero image │ └─ public/assets/img/ hero image
├─ modules/ installed modules, one directory each — a Docker bind mount; empty here
├─ Dockerfile builds client → serves via Express ├─ Dockerfile builds client → serves via Express
├─ docker-compose.yml app + MariaDB ├─ docker-compose.yml app + MariaDB
├─ .env.example root env (used by Compose) ├─ .env.example root env (used by Compose)
@@ -222,6 +227,10 @@ IMAGE_TAG=sha-042a151 docker compose pull && docker compose up -d
- Health check: `GET http://localhost:3000/api/health``{ "status": "ok" }` - Health check: `GET http://localhost:3000/api/health``{ "status": "ok" }`
- Logs: `docker compose logs -f app` (and `./logs/app.log` on the host) - Logs: `docker compose logs -f app` (and `./logs/app.log` on the host)
- Stop: `docker compose down` (add `-v` to also wipe the database + uploads volumes) - Stop: `docker compose down` (add `-v` to also wipe the database + uploads volumes)
- Modules: installed into `./modules` on the host (bind-mounted to `/app/modules`), never baked into
the image — an operator adds one to a pull-only deployment without building anything. Adding or
removing one takes a `docker compose restart app`; the scan is synchronous at startup. See
[`modules/README.md`](modules/README.md).
**Build the images locally instead of pulling** (offline, or to test an unmerged change) — overlay **Build the images locally instead of pulling** (offline, or to test an unmerged change) — overlay
the dev file, which adds `build:` back: the dev file, which adds `build:` back:
@@ -302,7 +311,7 @@ npm start # node server → serves API + SPA at http://localhost:3
| `/site/screenshots` | Screenshot gallery | | `/site/screenshots` | Screenshot gallery |
| `/site/five-on-friday` | Five on Friday | | `/site/five-on-friday` | Five on Friday |
| `/site/newsletter` · `/site/newsletter/:id` | Newsletter list + issue | | `/site/newsletter` · `/site/newsletter/:id` | Newsletter list + issue |
| `/site/about` · `/site/status` | About · Shard status | | `/site/about` · `/site/status` | About · Site status |
| `/wiki` · `/wiki/:slug` | Wiki landing + article (auto table-of-contents) | | `/wiki` · `/wiki/:slug` | Wiki landing + article (auto table-of-contents) |
**Admin** (cookie auth, `noindex`): **Admin** (cookie auth, `noindex`):
@@ -320,6 +329,13 @@ npm start # node server → serves API + SPA at http://localhost:3
| `/admin/users` | User management | | `/admin/users` | User management |
| `/admin/account` | Account security (self-service TOTP two-factor + linked SSO accounts) | | `/admin/account` | Account security (self-service TOTP two-factor + linked SSO accounts) |
**Player** (any signed-in account, `noindex`): `/player` and its self-service views. Staff are a
superset of players and reach these too.
An installed module adds its own pages under `/<id>/*`, `/admin/<id>/*` and `/player/<id>/*` — for
Module-uo that is `/uo/shard`, `/admin/uo/link`, `/player/uo/characters` and the rest. Core does not
know their names; they arrive with the module and are interleaved into the nav.
--- ---
## API endpoints ## API endpoints
@@ -331,9 +347,15 @@ npm start # node server → serves API + SPA at http://localhost:3
| SSO | `/api/v1/auth` (`providers` — public discovery; `sso/:provider/start`, `sso/:provider/link`, `sso/:provider/callback`) | redirect flow | | SSO | `/api/v1/auth` (`providers` — public discovery; `sso/:provider/start`, `sso/:provider/link`, `sso/:provider/callback`) | redirect flow |
| Public | `/api/v1/public` (`settings`, `status`, `posts/:category`, `posts/:category/:idOrSlug`, `wiki`, `wiki/:slug`, `contact`) | none | | Public | `/api/v1/public` (`settings`, `status`, `posts/:category`, `posts/:category/:idOrSlug`, `wiki`, `wiki/:slug`, `contact`) | none |
| Admin | `/api/v1/admin` (`dashboard`, `site-mode`, `posts`, `posts/upload`, `wiki`, `settings`, `activity`, `bot-activity`, `bot-activity/unban`, `auth/providers` (CRUD), `users`, `account`, `account/totp/*`, `account/identities`) | cookie (admin) | | Admin | `/api/v1/admin` (`dashboard`, `site-mode`, `posts`, `posts/upload`, `wiki`, `settings`, `activity`, `bot-activity`, `bot-activity/unban`, `auth/providers` (CRUD), `users`, `account`, `account/totp/*`, `account/identities`) | cookie (admin) |
| Public · Shard | `/api/v1/public/shard` (`status`, `feed`, `economy`, `online`, `idoc`, `stream`) | none | | Player | `/api/v1/player` (`me`, credentials, 2FA, identities, appeals) | cookie/bearer (any signed-in account) |
| Player · Shard | `/api/v1/player/shard` (`link`, `accounts`, `roster/:account`, `vendors/:account`, `char/:serial`, `sales`) | cookie/bearer (player) | | Modules | `/api/v1/public/modules` — id, name, version and capabilities of the modules currently serving | none |
| Admin · Shard | `/api/v1/admin/shard` (self linking, same as player) · `/api/v1/admin/uo-link` (`config`, `towncrier`, `stream`) | cookie (staff / admin) |
**Module routes are not in this table**, because they are not core's. An installed module mounts
under `/api/v1/public/<prefix>`, `/api/v1/admin/<prefix>` and `/api/v1/player/<prefix>`; which
prefixes exist depends on what is installed. Module-uo, for instance, serves 72 routes under
`/shard`, `/atlas` and `/uo-link` — see its own
[`routes.manifest.json`](https://gitea.whitlocktech.com/RunicGateway/Module-uo/src/branch/main/routes.manifest.json).
On a running instance, `/api/docs` lists everything, core and modules together.
Post categories (URL form): `news`, `five-on-friday`, `newsletter`, `screenshots`. Post categories (URL form): `news`, `five-on-friday`, `newsletter`, `screenshots`.
`authMethod` on a session ∈ `local · totp · mobile · google · discord · oidc`. `authMethod` on a session ∈ `local · totp · mobile · google · discord · oidc`.
@@ -376,73 +398,129 @@ npm run swagger # → server/swagger/swagger-output.json
If the generated spec is missing, the server logs a warning and simply disables `/api/docs` (it does If the generated spec is missing, the server logs a warning and simply disables `/api/docs` (it does
not crash). not crash).
**The committed spec is core only, and the served one is not.** swagger-autogen is *static
analysis* — it parses `src/app.js` as text and follows the literal `app.use(…)` chain — so it can
see neither an installed module (which arrives on a volume long after the image was built, and
mounts through a call no parser can follow) nor an extension slot (whose router is created empty and
filled later). Both are handled by merging a **fragment**:
- **Extension slots** contribute at generation time, from `server/swagger/slotSpecs.js`, so they are
in the committed file.
- **Modules** contribute at request time, from the `swagger-fragment.json` each one ships, merged by
`server/swagger/docsSpec.js`. So `/api/docs.json` on a running instance describes more than
`npm run swagger` produces here, and `swagger-output.json` stays reproducible on any machine
regardless of what is installed.
**Core wins every 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.
One thing worth knowing if you edit an annotation: swagger-autogen **reports a broken one and then
succeeds anyway**, dropping it. `npm run swagger` now captures those diagnostics and fails, which is
how two annotations that had been silently documenting an empty request body were found. If it
rejects yours, the usual causes are an object literal a brace short, or a `"` or backtick inside a
single-quoted description (it re-quotes both to `'` before evaluating).
### The route manifest (frozen URL surface)
`server/routes.manifest.json` is a generated, sorted `{ method, path }` list of every route the two
Express listeners actually expose. It is **not** documentation — it is the machine-checkable freeze of
the URL surface, so that carving the router files up by business capability
(`docs/website/API_V2_PLAN.md`) can be proved to move no URL instead of merely claiming it.
```bash
cd server
npm run routes:manifest # → routes.manifest.json + routes.guards.json
npm run routes:manifest -- --check # exit 1 if either file is stale (what CI runs)
```
The generator walks the live Express stack (runtime introspection, not source parsing — a route's path
sits on the line *after* `router.get(`, which defeats greps) and keeps only
`/api/**` and `/.well-known/**` plus the internal listener. The SPA catch-all, `/uploads`, `/brand`
and installed modules' `/modules/<id>` chunks are filesystem-conditional static mounts, not API
contract, so they are excluded and the output depends neither on whether the client has been built
nor on which modules are mounted.
Two generated files, two very different meanings:
| File | Meaning of a diff |
|---|---|
| `routes.manifest.json` | **Contract change.** A URL moved. Justify it in the PR description; never let one ride along in a "mechanical" refactor. |
| `routes.guards.json` | **Review aid.** Per route: handler count + the *named* middleware on its mount chain. Names are a hint only — `requireRole(...)` returns an anonymous arrow and cannot be seen — but a vanished `requireAuth` is unambiguous. |
Unlike the Swagger spec, the manifest is annotation-free: `swagger-output.json` documents intent (only
annotated routes appear), the manifest records reality.
--- ---
## Shard integration (uo-link) ## Modules
The site is wired to the live in-game world through **uo-link**, a standalone sidecar service that **Everything specific to a game is a module.** Core has no idea what an "account", a "character" or
runs next to the ServUO shard. Its source lives in a separate repo: a "shard" is; it provides seams, and a module fills them. That is what makes one image able to run a
**[RunicGateway/link](https://gitea.whitlocktech.com/RunicGateway/link)**. uo-link speaks the shard's internals and site for any game rather than for Ultima Online in particular.
exposes a small, authenticated HTTP + WebSocket API; this website is a *client* of it. The shard
itself is never exposed to the internet — only the sidecar is, and only the website's backend talks
to it.
### How it works The design of record is
[MODULE_SYSTEM.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_SYSTEM.md);
the normative contract — the one to read before writing a module — is
[MODULE_API.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md).
The worked example is [RunicGateway/Module-uo](https://gitea.whitlocktech.com/RunicGateway/Module-uo),
which is where everything this README used to describe under *Shard integration (uo-link)* now
lives: the sidecar client, the ingest dispatcher, account linking, the town crier, the spawn atlas,
and every page that renders them.
### An operator never builds anything
That constraint shapes the whole design. Installing a module is the WordPress-plugin experience — an
admin-panel action, or a directory dropped onto the `modules/` volume — because production runs a
prebuilt, pull-only image with no toolchain in it. So a module ships **assembled**: its client half
is a prebuilt ESM chunk that resolves React from a `window.__rg` global core owns (an import map
would have to be inline, and the CSP is `script-src 'self'`), and its one runtime dependency travels
inside the tarball.
``` ```
ServUO shard ──▶ uo-link sidecar (RunicGateway/link) ──▶ website backend ──▶ browser modules/
REST + WebSocket, bearer-auth ingest + REST same-origin JSON/SSE └─ uo/ one directory per module; the id is the directory name
├─ module.json id, version, coreApi range, mounts, extensions, capabilities
├─ swagger-fragment.json merged into /api/docs.json while the module is running
├─ server/ routers, models, and an idempotent schema.sql fragment
└─ client/dist/entry.js the prebuilt chunk, injected by utils/htmlShell.js
``` ```
- **Connection is admin-managed, not env.** The sidecar's base URL, WebSocket URL, shared-secret `modules/` is a bind mount in `docker-compose.yml`, so placing a directory there by hand is a
token, and protocol version are stored in the database (`uoLinkConfig`), edited from the supported install. The directory is tracked in git (via its README) on purpose: Docker recreates a
**Admin → Shard** panel. The token is **encrypted at rest** (AES-256-GCM) and is **write-only** in *missing* bind-mount source as `root:root`, and the container is uid 1000.
the API — it is never returned to any client and never sent to the browser. Every call the backend
makes carries `Authorization: Bearer <token>` and an `X-UOLink-Version` header (a protocol
mismatch fails fast with `409` instead of being mis-parsed).
- **Live ingest (WebSocket).** When enabled, the backend opens an outbound WebSocket to the sidecar
and receives a stream of game events — `mob.login`/`logout`, `char.vitals`, `economy.supply`,
`vendor.sale`, `player.death`/`murdered`, `house.decay` (IDOC), staff `audit.*`/`cheat.*`,
`link.request`, and `server.hello`/`shutdown`. A single dispatcher (`utils/shardIngest.js`) routes
each event: state-changing kinds update `shard_online` / `shard_economy` / `shard_houses`; notable
kinds are appended to an append-only `shard_events` log; high-frequency kinds (vitals, supply
ticks) only update state and are not logged. A changed boot id on `server.hello` is detected as a
restart and stale "online" rows are cleared. On reconnect the backend backfills missed events via
the sidecar's `/history`.
- **Live round-trips (REST).** For point-in-time reads the backend calls the sidecar directly —
`/char/serial/:serial`, `/roster/:account`, `/vendors/:account`, `/economy`, `/history` — plus
commands `/link/confirm` and `/towncrier`. The REST client (`utils/uoLinkClient.js`) **never
throws**: every call returns `{ ok, data, status }`, so a shard that is down or mid-restart
degrades to a `503`/retry banner instead of a 500.
- **Fan-out to the browser.** Ingested events are pushed to browsers over **Server-Sent Events**.
Two channels exist: a **public** stream carrying only a safe allowlist of kinds, and an
**admin-only** stream that also includes sensitive kinds (staff audit, cheat detection, login
attempts, IPs). Sensitive kinds can never leak onto the public channel.
### Account linking ### What a module gets, and what it may not do
A player (or staff member) proves ownership of a game account without sharing any game credentials: At boot, `app.js` scans the volume synchronously, validates each `module.json`, and calls the
module's `register(ctx, api)`:
1. In game, the player runs **`[link`** and receives a one-time code. - **`ctx` is everything core hands over** — the database, the logger, settings, the session reader,
2. On the website (Player portal, or Admin → Account for staff) they enter the code. push, the secret box, the middleware, the rate-limit factory, the activity log, and **express
3. The backend confirms the code with the sidecar (`POST /link/confirm`), which permanently tags the itself**. A module lives outside `server/`, so Node's resolver never reaches core's
game account with the website user id, and mirrors the link locally in `shard_account_links`. `node_modules`; anything it must share has to be handed to it, or there would be two Expresses and
two Reacts in one process.
- **`api` is everything it may register** — routes (one prefix per tier), an extension slot fill,
notification streams, a news-announce leg, a post hook, and `onBoot`/`onShutdown`.
- **It may not reach into core's tree**, mount outside its declared prefixes, or create tables
outside its `<id>_` prefix. Each of those is checked, in the module's CI and again by the loader.
That mirror is the authorization basis for character reads: roster/vendor/character-sheet endpoints Two things are guaranteed regardless of what a module does. **A failure never takes the site down**:
are **ownership-checked** so a user only sees accounts they linked. **Admins may view any the loader catches everything from `require` to `onBoot`, marks that module `startup_failed`, and
character**; players and editor/moderator staff are limited to their own linked accounts. the site comes up with its routes and nav absent and the reason on the admin screen. And **no URL of
core's may move** — a module that displaced one is caught by the frozen route manifest, which is
generated from a real core with the module loaded.
### What each audience sees ### What is running right now
| Surface | Endpoints | Who | Data | ```
|---|---|---|---| GET /api/v1/public/modules
| **Public** | `/api/v1/public/shard/*` (`status`, `feed`, `economy`, `online`, `idoc`, `stream`) | anyone | Shard up/down, gold-supply series, IDOC houses, a curated live feed, and **"Staff online"** — only players whose account is linked to a **staff** user (admin/editor/moderator), shown with name + map location. Linked *players* are never listed publicly; no vitals or account are exposed. | { "modules": [ { "id": "uo", "name": "Ultima Online", "version": "0.3.0",
| **Player** | `/api/v1/player/shard/*` (`link`, `accounts`, `roster/:account`, `vendors/:account`, `char/:serial`, `sales`) | logged-in player | Their own linked accounts: character rosters, character sheets, player-vendor snapshots, and recent vendor sales. | "capabilities": ["shard", "atlas", "market", …] } ] }
| **Admin** | `/api/v1/admin/shard/*` (self-linking, same as player) · `/api/v1/admin/uo-link/*` (`config`, `towncrier`, `stream`) | staff / admin | Staff link their own accounts like players; **admins** additionally read *any* character's data, edit the sidecar connection config, publish/remove **town-crier** messages, and subscribe to the full event stream (incl. audit/cheat). | ```
The sidecar URL and token are set once in **Admin → Shard**; if uo-link is not configured (or the Anonymous, database-free, never site-mode gated, and **`started` modules only** — a module that is
shard is offline), every shard surface degrades gracefully — the public page still renders, showing disabled or failed is absent, exactly as its routes and its nav already are. Clients feature-detect
the shard as offline. against it; they do not use it to decide what to load (the HTML shell injects each chunk's tag).
--- ---
@@ -455,6 +533,7 @@ Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`.
| `NODE_ENV` | `production` | | | `NODE_ENV` | `production` | |
| `PORT` | `3000` | server listens on `0.0.0.0:PORT` | | `PORT` | `3000` | server listens on `0.0.0.0:PORT` |
| `UPLOAD_DIR` | `<server>/uploads` | where post images are written (`/app/uploads`, volume-mounted, in Compose) | | `UPLOAD_DIR` | `<server>/uploads` | where post images are written (`/app/uploads`, volume-mounted, in Compose) |
| `MODULES_DIR` | `<repo>/modules` | where installed modules are scanned from (`/app/modules`, bind-mounted, in Compose) |
| `DB_HOST` / `DB_PORT` | `db` / `3306` | `db` in Compose; `127.0.0.1` for local dev | | `DB_HOST` / `DB_PORT` | `db` / `3306` | `db` in Compose; `127.0.0.1` for local dev |
| `DB_NAME` / `DB_USER` / `DB_PASSWORD` | `runic_gateway` / `runic` / — | app database credentials | | `DB_NAME` / `DB_USER` / `DB_PASSWORD` | `runic_gateway` / `runic` / — | app database credentials |
| `DB_ROOT_PASSWORD` | — | MariaDB root (Compose only) | | `DB_ROOT_PASSWORD` | — | MariaDB root (Compose only) |
@@ -476,15 +555,14 @@ Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`.
| `CLIENT_ORIGIN` | `http://localhost:5173` | enables CORS in dev only | | `CLIENT_ORIGIN` | `http://localhost:5173` | enables CORS in dev only |
| `LOG_LEVEL` / `FILE_LOG_LEVEL` | `info` / `debug` | console / file verbosity | | `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) | | `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_jobs` for legs that are due or retrying. Which legs exist is up to what has registered one — Discord is core's; a module may add its own |
| `TOWNCRIER_DURATION_SEC` | `3600` | how long a news post's in-game town-crier message stays up (≤ `86400`) |
--- ---
## Branding ## Branding
Instance identity is data, not code — set via `BRAND_*` env vars, so one prebuilt Instance identity is data, not code — set via `BRAND_*` env vars, so one prebuilt
image can run as any shard. With none set, everything renders as **Runic Gateway**. image can run as any community. With none set, everything renders as **Runic Gateway**.
| Var | What | | Var | What |
|---|---| |---|---|

View File

@@ -1,13 +1,83 @@
// Branding for the Discord bot. Mirrors the server's BRAND_* scheme so embeds and // Branding for the Discord bot. Mirrors the server's BRAND_* scheme so embeds and
// logs carry the instance identity. Kept minimal — the bot only needs the name // logs carry the instance identity. Kept minimal — the bot only needs the name
// and the accent color (as an int for discord.js embeds). // and the accent color (as an int for discord.js embeds).
//
// The accent additionally tracks ADMIN THEMING. An admin who re-themes the site
// changes `theme_visual`, which the server resolves into the effective
// `brand.accent` on GET /public/settings (docs/website/THEMING_AND_NAV.md
// §4.5). This process boots from env and then follows that value, so embeds
// don't stay the old color until someone restarts the container.
//
// Design constraints this satisfies:
// • env is always a working answer — a site that is down, unconfigured or
// mid-restart never costs the bot its accent, it just keeps the last known
// good one;
// • reading `brand.accentInt` never awaits and never throws, because it is
// read inline while building an embed;
// • at most one refresh is ever in flight.
require('dotenv').config() require('dotenv').config()
const name = process.env.BRAND_NAME || 'Runic Gateway' const siteApi = require('./site/siteApiClient')
const accentHex = process.env.BRAND_ACCENT_COLOR || '#7f99bd' const createLogger = require('./utils/logger')
const accentInt = (() => {
const n = parseInt(String(accentHex).replace('#', ''), 16)
return Number.isNaN(n) ? 0x7f99bd : n
})()
module.exports = { name, accentHex, accentInt } const log = createLogger('brand')
const name = process.env.BRAND_NAME || 'Runic Gateway'
const ENV_ACCENT = process.env.BRAND_ACCENT_COLOR || '#7f99bd'
function toInt(hex) {
const n = parseInt(String(hex).replace('#', ''), 16)
return Number.isNaN(n) ? 0x7f99bd : n
}
// How long a fetched accent is trusted before the next read triggers a refresh.
// A theme change reaching Discord within ten minutes is fine; a network call per
// embed is not.
const TTL_MS = 10 * 60 * 1000
let accentHex = ENV_ACCENT
let accentInt = toInt(ENV_ACCENT)
let fetchedAt = 0
let inFlight = null
async function fetchAccent() {
const res = await siteApi.getPublicSettings()
// Any failure — site down, maintenance, malformed body — leaves the current
// value in place. Stamping fetchedAt regardless is deliberate: it stops a
// persistently unreachable site from firing a request on every single read.
fetchedAt = Date.now()
const accent = res.ok ? res.data?.brand?.accent : null
if (typeof accent !== 'string' || !/^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(accent)) return
if (accent === accentHex) return
accentHex = accent
accentInt = toInt(accent)
log.info('embed accent updated from the site', { accent })
}
// Kick off a refresh if the cached value is stale. Never awaited by a reader —
// the current value is returned immediately and the next read sees the new one.
function refreshIfStale() {
if (inFlight || Date.now() - fetchedAt < TTL_MS) return inFlight
inFlight = fetchAccent()
.catch((err) => log.warn('accent refresh failed — keeping the current value', { message: err.message }))
.finally(() => {
inFlight = null
})
return inFlight
}
module.exports = {
name,
// Getters, not values: consumers already read `brand.accentInt` inline when
// building an embed, so this keeps the accent current with no call-site change.
get accentHex() {
refreshIfStale()
return accentHex
},
get accentInt() {
refreshIfStale()
return accentInt
},
// Awaited once at startup so the first embed of a process is already correct.
refreshAccent: () => refreshIfStale() || Promise.resolve(),
}

View File

@@ -21,6 +21,11 @@ async function start() {
log.info(`internal API listening on http://${HOST}:${PORT}`) log.info(`internal API listening on http://${HOST}:${PORT}`)
}) })
// Pick up the site's effective accent before the first embed can be built.
// Best-effort by design: it never rejects, and a site that is not up yet just
// leaves the bot on its BRAND_ACCENT_COLOR default until the next read.
await brand.refreshAccent()
await bootstrap() await bootstrap()
setupShutdown(server) setupShutdown(server)

View File

@@ -31,6 +31,15 @@ async function call(path) {
} }
} }
// The site's public settings, including the brand block. Used for the embed
// accent (see brand.js): the admin can theme the site at runtime, and the
// server resolves the effective accent into brand.accent, so this is how the
// bot's embeds track a theme change instead of being stuck on the value
// BRAND_ACCENT_COLOR had when the container started.
function getPublicSettings() {
return call('/settings')
}
function getNewsPost(idOrSlug) { function getNewsPost(idOrSlug) {
return call(`/posts/news/${encodeURIComponent(idOrSlug)}`) return call(`/posts/news/${encodeURIComponent(idOrSlug)}`)
} }
@@ -39,4 +48,4 @@ function searchWiki(query) {
return call(`/wiki?q=${encodeURIComponent(query)}`) return call(`/wiki?q=${encodeURIComponent(query)}`)
} }
module.exports = { getNewsPost, searchWiki } module.exports = { getPublicSettings, getNewsPost, searchWiki }

View File

@@ -7,7 +7,16 @@
<meta name="description" content="Runic Gateway — an independent private Ultima Online shard. News, screenshots, guides, and community notes." /> <meta name="description" content="Runic Gateway — an independent private Ultima Online shard. News, screenshots, guides, and community notes." />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600;700&display=swap" rel="stylesheet" /> <!-- The eight web families behind the admin font shortlist
(docs/website/THEMING_AND_NAV.md §5), in one combined css2? request.
Static and never built from admin input: the dropdown stores a full
font-family stack from a closed set, and only the families actually
applied have their binaries fetched. Both hosts are already in the CSP
(server/src/config/csp.js), so this needs no policy change. -->
<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"
/>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -8,6 +8,9 @@
"name": "runic-gateway-client", "name": "runic-gateway-client",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tiptap/extension-image": "^2.27.2", "@tiptap/extension-image": "^2.27.2",
"@tiptap/extension-link": "^2.27.2", "@tiptap/extension-link": "^2.27.2",
"@tiptap/extension-text-align": "^2.27.2", "@tiptap/extension-text-align": "^2.27.2",
@@ -306,6 +309,59 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz",
"integrity": "sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.1.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
@@ -2488,6 +2544,12 @@
"@popperjs/core": "^2.9.0" "@popperjs/core": "^2.9.0"
} }
}, },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/uc.micro": { "node_modules/uc.micro": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",

View File

@@ -10,6 +10,9 @@
"test": "node --test" "test": "node --test"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tiptap/extension-image": "^2.27.2", "@tiptap/extension-image": "^2.27.2",
"@tiptap/extension-link": "^2.27.2", "@tiptap/extension-link": "^2.27.2",
"@tiptap/extension-text-align": "^2.27.2", "@tiptap/extension-text-align": "^2.27.2",

View File

@@ -5,6 +5,8 @@ import MaintenanceGate from './components/MaintenanceGate.jsx'
import RequireAuth from './components/RequireAuth.jsx' import RequireAuth from './components/RequireAuth.jsx'
import RequirePlayer from './components/RequirePlayer.jsx' import RequirePlayer from './components/RequirePlayer.jsx'
import RoleGate from './components/RoleGate.jsx' import RoleGate from './components/RoleGate.jsx'
import { routesFor } from './modules/registry.js'
import { ModuleFeaturesProvider } from './modules/features.jsx'
// Public // Public
import Portal from './routes/public/Portal.jsx' import Portal from './routes/public/Portal.jsx'
@@ -16,12 +18,6 @@ import Newsletter from './routes/public/Newsletter.jsx'
import NewsletterIssue from './routes/public/NewsletterIssue.jsx' import NewsletterIssue from './routes/public/NewsletterIssue.jsx'
import About from './routes/public/About.jsx' import About from './routes/public/About.jsx'
import Status from './routes/public/Status.jsx' import Status from './routes/public/Status.jsx'
import Shard from './routes/public/Shard.jsx'
import ShardActivity from './routes/public/ShardActivity.jsx'
import ChampSpawns from './routes/public/ChampSpawns.jsx'
import Guilds from './routes/public/Guilds.jsx'
import Governors from './routes/public/Governors.jsx'
import Houses from './routes/public/Houses.jsx'
import Wiki from './routes/wiki/Wiki.jsx' import Wiki from './routes/wiki/Wiki.jsx'
import WikiArticle from './routes/wiki/WikiArticle.jsx' import WikiArticle from './routes/wiki/WikiArticle.jsx'
import CmsPage from './routes/public/CmsPage.jsx' import CmsPage from './routes/public/CmsPage.jsx'
@@ -35,19 +31,16 @@ import PagesAdmin from './routes/admin/views/PagesAdmin.jsx'
import PageBuilder from './routes/admin/views/PageBuilder.jsx' import PageBuilder from './routes/admin/views/PageBuilder.jsx'
import WikiAdmin from './routes/admin/views/WikiAdmin.jsx' import WikiAdmin from './routes/admin/views/WikiAdmin.jsx'
import HeroEditor from './routes/admin/views/HeroEditor.jsx' import HeroEditor from './routes/admin/views/HeroEditor.jsx'
import AppearanceAdmin from './routes/admin/views/AppearanceAdmin.jsx'
import NavEditor from './routes/admin/views/NavEditor.jsx'
import SettingsAdmin from './routes/admin/views/SettingsAdmin.jsx' import SettingsAdmin from './routes/admin/views/SettingsAdmin.jsx'
import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx' import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx'
import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx' import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx'
import DiscordBotAdmin from './routes/admin/views/DiscordBotAdmin.jsx' import DiscordBotAdmin from './routes/admin/views/DiscordBotAdmin.jsx'
import ShardAdmin from './routes/admin/views/ShardAdmin.jsx'
import ShardOps from './routes/admin/views/ShardOps.jsx'
import AdminCharacters from './routes/admin/views/AdminCharacters.jsx'
import AdminCharacter from './routes/admin/views/AdminCharacter.jsx'
import AuthProvidersAdmin from './routes/admin/views/AuthProvidersAdmin.jsx' import AuthProvidersAdmin from './routes/admin/views/AuthProvidersAdmin.jsx'
import UsersAdmin from './routes/admin/views/UsersAdmin.jsx' import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
import UserDetail from './routes/admin/views/UserDetail.jsx' import UserDetail from './routes/admin/views/UserDetail.jsx'
import InvitesAdmin from './routes/admin/views/InvitesAdmin.jsx' import InvitesAdmin from './routes/admin/views/InvitesAdmin.jsx'
import HousesAdmin from './routes/admin/views/HousesAdmin.jsx'
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx' import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
import Moderation from './routes/admin/views/Moderation.jsx' import Moderation from './routes/admin/views/Moderation.jsx'
import ModerationUser from './routes/admin/views/ModerationUser.jsx' import ModerationUser from './routes/admin/views/ModerationUser.jsx'
@@ -59,9 +52,7 @@ import PlayerRegister from './routes/player/PlayerRegister.jsx'
import ForgotPassword from './routes/player/ForgotPassword.jsx' import ForgotPassword from './routes/player/ForgotPassword.jsx'
import ResetPassword from './routes/player/ResetPassword.jsx' import ResetPassword from './routes/player/ResetPassword.jsx'
import AcceptInvite from './routes/player/AcceptInvite.jsx' import AcceptInvite from './routes/player/AcceptInvite.jsx'
import PlayerPortalLayout from './routes/player/PlayerPortalLayout.jsx' import PlayerPortalLayout, { PlayerIndex } from './routes/player/PlayerPortalLayout.jsx'
import PlayerCharacters from './routes/player/PlayerCharacters.jsx'
import PlayerCharacter from './routes/player/PlayerCharacter.jsx'
import PlayerAccount from './routes/player/PlayerAccount.jsx' import PlayerAccount from './routes/player/PlayerAccount.jsx'
import PlayerAppeals from './routes/player/PlayerAppeals.jsx' import PlayerAppeals from './routes/player/PlayerAppeals.jsx'
@@ -69,6 +60,13 @@ export default function App() {
return ( return (
<AuthProvider> <AuthProvider>
<SiteProvider> <SiteProvider>
{/* Inside the auth and site contexts, because a feature provider is a
hook that may well read either — a live-status one does, indirectly,
by asking an endpoint whose answer depends on the session. Outside the
routes, so the nav in every layout is filtered by the same gate and
the provider hooks are called once for the whole app rather than
once per screen. */}
<ModuleFeaturesProvider>
<Routes> <Routes>
{/* Landing hero — always public, even in maintenance mode. The hero is {/* Landing hero — always public, even in maintenance mode. The hero is
itself the pre-launch "coming soon" page, so it sits outside the itself the pre-launch "coming soon" page, so it sits outside the
@@ -91,14 +89,18 @@ export default function App() {
<Route path="/site/newsletter/:id" element={<NewsletterIssue />} /> <Route path="/site/newsletter/:id" element={<NewsletterIssue />} />
<Route path="/site/about" element={<About />} /> <Route path="/site/about" element={<About />} />
<Route path="/site/status" element={<Status />} /> <Route path="/site/status" element={<Status />} />
<Route path="/site/shard" element={<Shard />} />
<Route path="/site/shard/activity" element={<ShardActivity />} />
<Route path="/site/champs" element={<ChampSpawns />} />
<Route path="/site/guilds" element={<Guilds />} />
<Route path="/site/governors" element={<Governors />} />
<Route path="/site/houses" element={<Houses />} />
<Route path="/wiki" element={<Wiki />} /> <Route path="/wiki" element={<Wiki />} />
<Route path="/wiki/:slug" element={<WikiArticle />} /> <Route path="/wiki/:slug" element={<WikiArticle />} />
{/* Installed modules' public pages, namespaced `/<id>/…` — the
registry prefixes the segment, so a module cannot spell its way
out of it (docs/website/MODULE_API.md §3.3). Declared before the
CMS catch-all below: React Router ranks a static segment over a
dynamic one, so the order is not what saves us, but keeping the
two adjacent makes the relationship visible to whoever adds the
next route here. */}
{routesFor('public').map((r) => (
<Route key={r.path} path={`/${r.path}`} element={r.element} />
))}
{/* CMS pages: top-level /:slug, matched only after the named routes {/* CMS pages: top-level /:slug, matched only after the named routes
above (React Router ranks static routes over this dynamic one). */} above (React Router ranks static routes over this dynamic one). */}
<Route path="/:slug" element={<CmsPage />} /> <Route path="/:slug" element={<CmsPage />} />
@@ -125,6 +127,28 @@ export default function App() {
<Route path="pages/:id" element={<PageBuilder />} /> <Route path="pages/:id" element={<PageBuilder />} />
<Route path="wiki" element={<WikiAdmin />} /> <Route path="wiki" element={<WikiAdmin />} />
<Route path="hero" element={<HeroEditor />} /> <Route path="hero" element={<HeroEditor />} />
{/* Theme editing writes an admin-only settings key; the route sits
behind the same RoleGate as the sidebar entry that reaches it,
and PUT/DELETE /admin/settings is admin-only server-side too. */}
<Route
path="appearance"
element={
<RoleGate roles={['admin']}>
<AppearanceAdmin />
</RoleGate>
}
/>
{/* Same reasoning as Appearance: the nav overrides are an admin-only
settings key, so the route carries the same RoleGate as the
sidebar entry that reaches it. */}
<Route
path="navigation"
element={
<RoleGate roles={['admin']}>
<NavEditor />
</RoleGate>
}
/>
<Route path="settings" element={<SettingsAdmin />} /> <Route path="settings" element={<SettingsAdmin />} />
<Route <Route
path="moderation" path="moderation"
@@ -141,30 +165,24 @@ export default function App() {
<Route path="activity" element={<ActivityAdmin />} /> <Route path="activity" element={<ActivityAdmin />} />
<Route path="bot-activity" element={<BotActivityAdmin />} /> <Route path="bot-activity" element={<BotActivityAdmin />} />
<Route path="discord-bot" element={<DiscordBotAdmin />} /> <Route path="discord-bot" element={<DiscordBotAdmin />} />
<Route path="shard" element={<ShardAdmin />} />
<Route
path="shard-ops"
element={
<RoleGate roles={['admin', 'moderator']}>
<ShardOps />
</RoleGate>
}
/>
<Route
path="houses"
element={
<RoleGate roles={['admin', 'moderator']}>
<HousesAdmin />
</RoleGate>
}
/>
<Route path="characters" element={<AdminCharacters />} />
<Route path="characters/:serial" element={<AdminCharacter />} />
<Route path="auth-providers" element={<AuthProvidersAdmin />} /> <Route path="auth-providers" element={<AuthProvidersAdmin />} />
<Route path="users" element={<UsersAdmin />} /> <Route path="users" element={<UsersAdmin />} />
<Route path="users/:id" element={<UserDetail />} /> <Route path="users/:id" element={<UserDetail />} />
<Route path="invites" element={<InvitesAdmin />} /> <Route path="invites" element={<InvitesAdmin />} />
<Route path="account" element={<AccountAdmin />} /> <Route path="account" element={<AccountAdmin />} />
{/* Installed modules' admin pages, at /admin/<id>/…, already inside
RequireAuth + AdminLayout. A module cannot supply its own auth
wrapper — only an optional { roles }, which core applies as the
same RoleGate its own routes above use, so the sidebar and the
route table cannot disagree about who may see what. Before the
`*` redirect, which would otherwise swallow every one of them. */}
{routesFor('admin').map((r) => (
<Route
key={r.path}
path={r.path}
element={r.gate ? <RoleGate roles={r.gate.roles}>{r.element}</RoleGate> : r.element}
/>
))}
<Route path="*" element={<Navigate to="/admin" replace />} /> <Route path="*" element={<Navigate to="/admin" replace />} />
</Route> </Route>
@@ -181,14 +199,29 @@ export default function App() {
</RequirePlayer> </RequirePlayer>
} }
> >
<Route path="/player" element={<PlayerCharacters />} /> {/* The portal index resolves to the first nav row this viewer can
<Route path="/player/char/:serial" element={<PlayerCharacter />} /> reach rather than naming a page: `PlayerCharacters` was a UO
page and left with the client half (MODULE_SYSTEM.md §2.7.1).
With the UO module installed that is still Characters. */}
<Route path="/player" element={<PlayerIndex />} />
<Route path="/account" element={<PlayerAccount />} /> <Route path="/account" element={<PlayerAccount />} />
<Route path="/account/appeals" element={<PlayerAppeals />} /> <Route path="/account/appeals" element={<PlayerAppeals />} />
{/* Installed modules' player-portal pages, at /player/<id>/…. This
group's own routes are absolute (its layout route has no path),
so the prefix is written here rather than inherited — the one
place the three areas do not read alike. */}
{routesFor('player').map((r) => (
<Route
key={r.path}
path={`/player/${r.path}`}
element={r.gate ? <RoleGate roles={r.gate.roles}>{r.element}</RoleGate> : r.element}
/>
))}
</Route> </Route>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</ModuleFeaturesProvider>
</SiteProvider> </SiteProvider>
</AuthProvider> </AuthProvider>
) )

View File

@@ -42,6 +42,20 @@ function safeParse(text) {
} }
} }
// The request PRIMITIVE, exported for installed modules and handed to them on
// `window.__rg.api` (docs/website/MODULE_API.md §3.5). Core owns the fetch
// semantics — same-origin /api/v1, cookies included, JSON in and out, ApiError
// on a non-2xx — and nothing above them: a module owns the paths it calls,
// because it owns the routes at the other end.
//
// The `api` object below is core's own binding surface and nothing else: every
// namespace in it belongs to a route core still serves. A module binds its own
// paths in its own chunk, against this primitive.
// `BASE` goes with it: a module that needs an EventSource URL cannot go through
// `req` (fetch-only) and must not hardcode `/api/v1`, which is core's choice of
// mount point and not a promise it has made.
export { req as request, BASE }
export const api = { export const api = {
// ----- auth ----- // ----- auth -----
me: () => req('/auth/me'), me: () => req('/auth/me'),
@@ -71,8 +85,10 @@ export const api = {
resetPassword: (token, password) => resetPassword: (token, password) =>
req(`/auth/password/reset/${encodeURIComponent(token)}`, { method: 'POST', body: { password } }), req(`/auth/password/reset/${encodeURIComponent(token)}`, { method: 'POST', body: { password } }),
// Second factor for an SSO login (challenge is held in an httpOnly cookie set by // Second factor for an SSO login (challenge is held in an httpOnly cookie set by
// the callback, so only the code is sent). Returns { user, returnTo }. // the callback, so only the code is sent). `extra` carries the trustDevice/
ssoLoginTotp: (code) => req('/auth/sso/totp', { method: 'POST', body: { code } }), // deviceName opt-in, same as the password path. Returns { user, returnTo } — plus
// { trustLimitReached, devices } when trust was asked for but the cap is reached.
ssoLoginTotp: (code, extra = {}) => req('/auth/sso/totp', { method: 'POST', body: { code, ...extra } }),
logout: () => req('/auth/logout', { method: 'POST' }), logout: () => req('/auth/logout', { method: 'POST' }),
// Public SSO provider discovery — drives the login-page provider buttons. // Public SSO provider discovery — drives the login-page provider buttons.
authProviders: () => req('/auth/providers'), authProviders: () => req('/auth/providers'),
@@ -95,6 +111,14 @@ export const api = {
generateRecoveryCodes: (currentPassword) => generateRecoveryCodes: (currentPassword) =>
req('/auth/me/account/recovery-codes/generate', { method: 'POST', body: { currentPassword } }), req('/auth/me/account/recovery-codes/generate', { method: 'POST', body: { currentPassword } }),
// ----- settings (any authenticated account) -----
// Nav overrides for the layouts the caller's own role renders, and the theme
// catalog the appearance form is built from. A fifth group, not part of
// /admin, because AdminLayout renders for editors and moderators too — see
// docs/website/THEMING_AND_NAV.md §4.2.
navSettings: () => req('/settings/nav'),
themeOptions: () => req('/settings/theme/options'),
// ----- public ----- // ----- public -----
publicSettings: () => req('/public/settings'), publicSettings: () => req('/public/settings'),
status: () => req('/public/status'), status: () => req('/public/status'),
@@ -117,41 +141,6 @@ export const api = {
pagePreview: (id, token) => req(`/public/pages/${id}/preview/${token}`), pagePreview: (id, token) => req(`/public/pages/${id}/preview/${token}`),
contact: (payload) => req('/public/contact', { method: 'POST', body: payload }), contact: (payload) => req('/public/contact', { method: 'POST', body: payload }),
// ----- shard live data (uo-link) -----
// Token-free, same-origin reads backed by the ingested feed + a cached live
// character round-trip. shardStreamUrl is the SSE endpoint for useShardFeed.
shard: {
status: () => req('/public/shard/status'),
feed: (opts = {}) => {
const qs = new URLSearchParams()
if (opts.kind) qs.set('kind', opts.kind)
if (opts.limit) qs.set('limit', opts.limit)
const s = qs.toString()
return req(`/public/shard/feed${withQs(s)}`)
},
economy: (limit) => {
const q = limit ? `limit=${limit}` : ''
return req(`/public/shard/economy${withQs(q)}`)
},
online: () => req('/public/shard/online'),
idoc: () => req('/public/shard/idoc'),
champs: () => req('/public/shard/champs'),
// Protocol 2.0 boards.
guilds: () => req('/public/shard/guilds'),
governors: () => req('/public/shard/governors'),
governorHistory: (city, limit) => {
const q = limit ? `limit=${limit}` : ''
return req(`/public/shard/governors/${encodeURIComponent(city)}/history${withQs(q)}`)
},
presence: () => req('/public/shard/presence'),
houses: () => req('/public/shard/houses'),
},
// Full paths (incl. /api/v1) for the browser EventSource — the req() wrapper is
// fetch-only, so SSE subscribers build the URL from here. The admin stream
// carries every kind (incl. audit/cheat) and needs the staff session cookie.
shardStreamUrl: `${BASE}/public/shard/stream`,
adminShardStreamUrl: `${BASE}/admin/uo-link/stream`,
// ----- admin ----- // ----- admin -----
admin: { admin: {
dashboard: () => req('/admin/dashboard'), dashboard: () => req('/admin/dashboard'),
@@ -209,6 +198,20 @@ export const api = {
deleteWikiCategory: (id) => req(`/admin/wiki/categories/${id}`, { method: 'DELETE' }), deleteWikiCategory: (id) => req(`/admin/wiki/categories/${id}`, { method: 'DELETE' }),
getSettings: () => req('/admin/settings'), getSettings: () => req('/admin/settings'),
updateSettings: (obj) => req('/admin/settings', { method: 'PUT', body: obj }), updateSettings: (obj) => req('/admin/settings', { method: 'PUT', body: obj }),
// Reset one setting to its default by deleting the row — the theming/nav
// keys and the hero draft only (the server holds the allowlist). Idempotent,
// so the caller need not know whether a row exists.
resetSetting: (key) => req(`/admin/settings/${encodeURIComponent(key)}`, { method: 'DELETE' }),
// Upload one brand asset (logo | hero | favicon) and set it as the override
// in the same call → { url, brand_assets }. A separate endpoint from the
// generic upload above because the server applies per-slot rules (favicons
// are PNG-only and capped small) and writes the settings row itself, so an
// upload never leaves a file nothing points at.
uploadBrandAsset: (slot, file) => {
const fd = new FormData()
fd.append('image', file)
return req(`/admin/settings/brand-asset/${encodeURIComponent(slot)}`, { method: 'POST', body: fd, raw: true })
},
activity: (limit = 50) => req(`/admin/activity?limit=${limit}`), activity: (limit = 50) => req(`/admin/activity?limit=${limit}`),
botActivity: () => req('/admin/bot-activity'), botActivity: () => req('/admin/bot-activity'),
unbanIp: (ip) => req('/admin/bot-activity/unban', { method: 'POST', body: { ip } }), unbanIp: (ip) => req('/admin/bot-activity/unban', { method: 'POST', body: { ip } }),
@@ -229,21 +232,6 @@ export const api = {
createInvite: (email, role, sendEmail = true) => createInvite: (email, role, sendEmail = true) =>
req('/admin/invites', { method: 'POST', body: { email, role, sendEmail } }), req('/admin/invites', { method: 'POST', body: { email, role, sendEmail } }),
revokeInvite: (id) => req(`/admin/invites/${id}`, { method: 'DELETE' }), revokeInvite: (id) => req(`/admin/invites/${id}`, { method: 'DELETE' }),
// A single user's shard (uo-link) footprint, scoped to their linked accounts.
// accounts/sales/houses/online are user-scoped endpoints; roster/vendors/char
// reuse the admin-bypass /admin/shard/* endpoints (which already read any
// account) so the shared GameAccounts component works unchanged.
userShard: (id) => ({
accounts: () => req(`/admin/users/${id}/shard/accounts`),
roster: (account) => req(`/admin/shard/roster/${encodeURIComponent(account)}`),
vendors: (account) => req(`/admin/shard/vendors/${encodeURIComponent(account)}`),
char: (serial) => req(`/admin/shard/char/${encodeURIComponent(serial)}`),
sales: () => req(`/admin/users/${id}/shard/sales`),
houses: () => req(`/admin/users/${id}/shard/houses`),
online: () => req(`/admin/users/${id}/shard/online`),
standing: () => req(`/admin/users/${id}/shard/standing`),
unlink: (account) => req(`/admin/users/${id}/shard/link/${encodeURIComponent(account)}`, { method: 'DELETE' }),
}),
// ----- moderation dashboard (admin + moderator) ----- // ----- moderation dashboard (admin + moderator) -----
modSummary: () => req('/admin/moderation/stats/summary'), modSummary: () => req('/admin/moderation/stats/summary'),
@@ -316,19 +304,6 @@ export const api = {
linkedIdentities: () => req('/admin/account/identities'), linkedIdentities: () => req('/admin/account/identities'),
unlinkIdentity: (provider) => req(`/admin/account/identities/${provider}`, { method: 'DELETE' }), unlinkIdentity: (provider) => req(`/admin/account/identities/${provider}`, { method: 'DELETE' }),
// ----- game account linking (self-service, staff) -----
shard: {
link: (code) => req('/admin/shard/link', { method: 'POST', body: { code } }),
accounts: () => req('/admin/shard/accounts'),
roster: (account) => req(`/admin/shard/roster/${encodeURIComponent(account)}`),
vendors: (account) => req(`/admin/shard/vendors/${encodeURIComponent(account)}`),
char: (serial) => req(`/admin/shard/char/${encodeURIComponent(serial)}`),
sales: () => req('/admin/shard/sales'),
houses: () => req('/admin/shard/houses'), // full registry (admin/moderator)
createAccount: (account, password) =>
req('/admin/shard/account', { method: 'POST', body: { account, password } }),
},
// ----- auth providers / SSO config (admin only) ----- // ----- auth providers / SSO config (admin only) -----
listAuthProviders: () => req('/admin/auth/providers'), listAuthProviders: () => req('/admin/auth/providers'),
createAuthProvider: (data) => req('/admin/auth/providers', { method: 'POST', body: data }), createAuthProvider: (data) => req('/admin/auth/providers', { method: 'POST', body: data }),
@@ -339,26 +314,6 @@ export const api = {
getDiscordBotConfig: () => req('/admin/discord-bot/config'), getDiscordBotConfig: () => req('/admin/discord-bot/config'),
saveDiscordBotConfig: (data) => req('/admin/discord-bot/config', { method: 'PUT', body: data }), saveDiscordBotConfig: (data) => req('/admin/discord-bot/config', { method: 'PUT', body: data }),
// ----- uo-link sidecar control (admin only) -----
getUoLinkConfig: () => req('/admin/uo-link/config'),
saveUoLinkConfig: (data) => req('/admin/uo-link/config', { method: 'PUT', body: data }),
postTownCrier: (data) => req('/admin/uo-link/towncrier', { method: 'POST', body: data }),
deleteTownCrier: (id) => req(`/admin/uo-link/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' }),
// ----- in-game staff operations: write plane + support queue (admin/moderator) -----
// `actor` is stamped server-side from the session — never sent from here.
shardOps: {
kick: (data) => req('/admin/shard/kick', { method: 'POST', body: data }),
ban: (data) => req('/admin/shard/ban', { method: 'POST', body: data }),
unban: (account) => req('/admin/shard/unban', { method: 'POST', body: { account } }),
broadcast: (data) => req('/admin/shard/broadcast', { method: 'POST', body: data }),
pages: () => req('/admin/shard/pages'),
respondPage: (id, data) =>
req(`/admin/shard/pages/${encodeURIComponent(id)}/respond`, { method: 'POST', body: data }),
closePage: (id) => req(`/admin/shard/pages/${encodeURIComponent(id)}/close`, { method: 'POST' }),
audit: (limit) => req(`/admin/shard/audit${limit ? `?limit=${limit}` : ''}`),
},
// ----- Email delivery / Gmail OAuth2 (admin only) ----- // ----- Email delivery / Gmail OAuth2 (admin only) -----
getEmailConfig: () => req('/admin/email/config'), getEmailConfig: () => req('/admin/email/config'),
saveEmailConfig: (data) => req('/admin/email/config', { method: 'PUT', body: data }), saveEmailConfig: (data) => req('/admin/email/config', { method: 'PUT', body: data }),
@@ -382,19 +337,6 @@ export const api = {
linkedIdentities: () => req('/player/account/identities'), linkedIdentities: () => req('/player/account/identities'),
unlinkIdentity: (provider) => req(`/player/account/identities/${provider}`, { method: 'DELETE' }), unlinkIdentity: (provider) => req(`/player/account/identities/${provider}`, { method: 'DELETE' }),
// ----- game account linking (uo-link) -----
shard: {
link: (code) => req('/player/shard/link', { method: 'POST', body: { code } }),
accounts: () => req('/player/shard/accounts'),
roster: (account) => req(`/player/shard/roster/${encodeURIComponent(account)}`),
vendors: (account) => req(`/player/shard/vendors/${encodeURIComponent(account)}`),
char: (serial) => req(`/player/shard/char/${encodeURIComponent(serial)}`),
sales: () => req('/player/shard/sales'),
houses: () => req('/player/shard/houses'), // the caller's own houses
createAccount: (account, password) =>
req('/player/shard/account', { method: 'POST', body: { account, password } }),
},
// ----- moderation appeals (self-service) ----- // ----- moderation appeals (self-service) -----
getMyAppeals: () => req('/player/appeals'), getMyAppeals: () => req('/player/appeals'),
getEligibleAppeals: () => req('/player/appeals/eligible'), getEligibleAppeals: () => req('/player/appeals/eligible'),

View File

@@ -0,0 +1,33 @@
import { useSite } from '../contexts/SiteContext.jsx'
// The instance logo, shown beside the MoonDot wherever the site says its own
// name (docs/website/THEMING_AND_NAV.md phase 5).
//
// Renders NOTHING unless this instance has a logo — `brand.logo` is the uploaded
// override or BRAND_LOGO, and its default is the empty string. That is what
// keeps an untouched instance byte-for-byte as today: the MoonDot stands alone
// exactly as it does now, and the logo is an addition an operator opts into.
//
// It sits beside the moon rather than replacing it. The moon is the app's own
// mark and appears on surfaces (maintenance, login) that must render before the
// settings fetch resolves; swapping it out would leave those momentarily blank.
//
// Deliberately not used for the footer's "powered by Runic Gateway" emblem
// (SiteFooter.jsx) — that badge is the project's mark, not the instance's, and
// must not follow brand_assets (§4.11).
export default function BrandLogo({ height = 22, alt = '', style }) {
const { brand, siteTitle } = useSite()
if (!brand.logo) return null
return (
<img
src={brand.logo}
// Decorative by default: every call site puts the site title in text right
// next to it, so alt text here would have a screen reader say the name
// twice. A caller that renders the logo alone passes its own alt.
alt={alt || ''}
aria-hidden={alt ? undefined : true}
title={siteTitle}
style={{ height, width: 'auto', maxWidth: height * 6, objectFit: 'contain', display: 'block', ...style }}
/>
)
}

View File

@@ -1,202 +0,0 @@
// Reusable character-sheet renderer for the char.profile shape returned by
// /public/shard/char/:serial. Presentational only — the parent handles loading
// and errors. Styled with the shared theme vocabulary (panel/grid/stat tiles).
//
// `moderation` opts in the in-game kick/ban controls for the character's account;
// they self-gate to staff (ShardAccountActions), so passing it from a page a
// player can reach is safe.
import ShardAccountActions from './ShardAccountActions.jsx'
const RESIST_LABELS = { phys: 'Physical', fire: 'Fire', cold: 'Cold', pois: 'Poison', energy: 'Energy' }
// The char.profile `titles` block (Protocol 2.0). fameKarma/skill are already
// computed display strings; reward entries may be a cliloc NUMBER-as-string or a
// literal string. Without a cliloc table on the site we can only show literals, so
// numeric reward entries are skipped rather than shown as a raw number. Returns a
// de-duped list of human-readable title chips.
function displayTitles(titles) {
if (!titles) return []
const out = []
if (titles.fameKarma) out.push(titles.fameKarma)
if (titles.skill) out.push(titles.skill)
const reward = Array.isArray(titles.reward) ? titles.reward : []
const sel = typeof titles.selected === 'number' ? titles.selected : -1
// Prefer the selected reward title; fall back to the first literal one.
const candidate = sel >= 0 && sel < reward.length ? reward[sel] : reward.find((r) => r && !/^\d+$/.test(String(r)))
if (candidate && !/^\d+$/.test(String(candidate))) out.push(String(candidate))
return [...new Set(out.filter(Boolean))]
}
function TitleChip({ children, tone = 'var(--muted)' }) {
return (
<span
className="sans"
style={{
fontSize: '0.72rem', padding: '3px 9px', borderRadius: 999,
border: `1px solid ${tone}55`, color: tone, whiteSpace: 'nowrap',
}}
>
{children}
</span>
)
}
function StatTile({ value, label }) {
return (
<div className="panel" style={{ padding: '14px 12px', textAlign: 'center' }}>
<div className="display" style={{ fontSize: '1.35rem', color: 'var(--head)' }}>{value}</div>
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.64rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginTop: 4 }}>{label}</div>
</div>
)
}
function Vital({ label, cur, max }) {
const pct = max ? Math.min(100, Math.round((cur / max) * 100)) : 0
return (
<div className="panel" style={{ padding: '12px 14px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 8 }}>
<span className="sans" style={{ color: 'var(--accent)', fontSize: '0.64rem', letterSpacing: '0.12em', textTransform: 'uppercase' }}>{label}</span>
<span className="display" style={{ color: 'var(--head)', fontSize: '0.95rem' }}>{cur ?? '—'}<span className="dim" style={{ fontSize: '0.8rem' }}> / {max ?? '—'}</span></span>
</div>
<div style={{ height: 6, borderRadius: 999, background: 'var(--line)', overflow: 'hidden' }}>
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
</div>
</div>
)
}
export default function CharacterSheet({ char, moderation = false }) {
if (!char) return null
const stats = char.stats || {}
const resist = stats.resist || {}
// Skills the character actually has, best first.
const skills = (char.skills || [])
.filter((s) => (s.value || s.base || 0) > 0)
.sort((a, b) => (b.value || 0) - (a.value || 0))
const equipment = char.equipment || []
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 22 }}>
{/* Identity */}
<div style={{ display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap' }}>
<h2 className="display" style={{ margin: 0, fontSize: '1.6rem', color: 'var(--head)' }}>{char.name || 'Unknown'}</h2>
{char.title && <span className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem' }}>{char.title}</span>}
<span
className="sans"
style={{
display: 'inline-flex', alignItems: 'center', gap: 6, padding: '4px 10px', borderRadius: 999,
border: '1px solid var(--line)', fontSize: '0.74rem',
color: char.online ? '#7fd0a4' : 'var(--muted)',
}}
>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: char.online ? '#7fd0a4' : 'var(--dim)' }} />
{char.online ? 'Online' : 'Offline'}
</span>
<span className="sans dim" style={{ fontSize: '0.76rem', marginLeft: 'auto' }}>{char.serial}</span>
</div>
{/* Titles + standing (guild led / governorship) — all optional */}
{(displayTitles(char.titles).length > 0 || char.guild || (char.governorOf && char.governorOf.length > 0)) && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: -8 }}>
{char.governorOf && char.governorOf.map((city) => (
<TitleChip key={`gov-${city}`} tone="#c9a24b">Governor of {city}</TitleChip>
))}
{char.guild && (
<TitleChip tone="var(--accent)">
Guildmaster{char.guild.abbr ? `, [${char.guild.abbr}]` : ''} {char.guild.name}
</TitleChip>
)}
{displayTitles(char.titles).map((t) => <TitleChip key={t}>{t}</TitleChip>)}
</div>
)}
{/* Staff moderation for this character's account (self-gates to staff). */}
{moderation && char.acct && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, padding: '12px 14px', border: '1px solid var(--line-soft)', borderRadius: 10, background: 'rgba(255,255,255,0.02)' }}>
<span className="sans dim" style={{ fontSize: '0.76rem' }}>Account <strong style={{ color: 'var(--ink)' }}>{char.acct}</strong></span>
<ShardAccountActions account={char.acct} />
</div>
)}
{/* Core stats */}
<section>
<div className="field-label" style={{ marginBottom: 8 }}>Attributes</div>
<div className="grid-3" style={{ gap: 12 }}>
<StatTile value={stats.str ?? '—'} label="Strength" />
<StatTile value={stats.dex ?? '—'} label="Dexterity" />
<StatTile value={stats.int ?? '—'} label="Intelligence" />
</div>
<div className="grid-3" style={{ gap: 12, marginTop: 12 }}>
<Vital label="Hits" cur={stats.hits} max={stats.hitsMax} />
<Vital label="Mana" cur={stats.mana} max={stats.manaMax} />
<Vital label="Stamina" cur={stats.stam} max={stats.stamMax} />
</div>
</section>
{/* Resistances */}
{Object.keys(resist).length > 0 && (
<section>
<div className="field-label" style={{ marginBottom: 8 }}>Resistances</div>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
{['phys', 'fire', 'cold', 'pois', 'energy'].map((k) => (
<div key={k} className="panel" style={{ padding: '10px 16px', textAlign: 'center', minWidth: 84 }}>
<div className="display" style={{ color: 'var(--head)', fontSize: '1.1rem' }}>{resist[k] ?? 0}</div>
<div className="sans" style={{ color: 'var(--muted)', fontSize: '0.66rem', textTransform: 'uppercase', letterSpacing: '0.08em', marginTop: 2 }}>{RESIST_LABELS[k]}</div>
</div>
))}
</div>
</section>
)}
{/* Skills */}
{skills.length > 0 && (
<section>
<div className="field-label" style={{ marginBottom: 8 }}>Skills <span className="dim">({skills.length})</span></div>
<div className="grid-2" style={{ gap: '8px 18px' }}>
{skills.map((s) => {
const cap = s.cap || 100
const pct = Math.min(100, Math.round(((s.value || 0) / cap) * 100))
return (
<div key={s.n}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 3 }}>
<span className="sans" style={{ color: 'var(--ink)', fontSize: '0.86rem' }}>{s.n}</span>
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.82rem' }}>{s.value}</span>
</div>
<div style={{ height: 4, borderRadius: 999, background: 'var(--line)', overflow: 'hidden' }}>
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
</div>
</div>
)
})}
</div>
</section>
)}
{/* Equipment */}
{equipment.length > 0 && (
<section>
<div className="field-label" style={{ marginBottom: 8 }}>Equipment</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{equipment.map((it) => (
<div key={it.serial} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
<span style={{ flex: 'none', width: 22, height: 22, borderRadius: 5, border: '1px solid var(--line)', background: 'rgba(255,255,255,0.05)' }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.88rem' }}>{it.layer || 'Item'}</div>
<div className="sans dim" style={{ fontSize: '0.74rem' }}>id {it.itemId}{it.hue ? ` · hue ${it.hue}` : ''}</div>
</div>
{it.mods && Object.keys(it.mods).length > 0 && (
<div className="sans" style={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'flex-end', maxWidth: '55%' }}>
{Object.entries(it.mods).map(([k, v]) => (
<span key={k} className="pill" style={{ fontSize: '0.7rem', padding: '2px 8px' }}>{k} {v}</span>
))}
</div>
)}
</div>
))}
</div>
</section>
)}
</div>
)
}

View File

@@ -1,79 +0,0 @@
import { useEffect, useState } from 'react'
// A small stat-tile row for a "My Characters" page: total characters, how many
// are online right now, and how many game accounts are linked. `scope` is the
// shard api object (admin or player self-service). Renders nothing until an
// account is linked, so the empty/link-prompt state below it stands alone.
//
// It fetches the same rosters GameAccounts loads; for a personal page that's at
// most a couple of extra live round-trips, and keeps this presentational bit
// decoupled from GameAccounts' per-account roster loading.
function Tile({ value, label }) {
return (
<div className="panel" style={{ padding: 20, textAlign: 'center' }}>
<div className="display" style={{ fontSize: '1.6rem', color: 'var(--head)' }}>{value}</div>
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.68rem', fontWeight: 700, letterSpacing: '0.15em', textTransform: 'uppercase', marginTop: 8 }}>
{label}
</div>
</div>
)
}
// Fold the settled roster results into totals. `complete` is false when any
// account's roster failed (a partial result — shown as a dash rather than a
// misleadingly low count).
function summarizeRosters(rosters) {
let chars = 0
let online = 0
let complete = true
for (const r of rosters) {
if (r.status !== 'fulfilled') {
complete = false
continue
}
const cs = r.value.chars || []
chars += cs.length
online += cs.filter((c) => c.online).length
}
return { chars, online, complete }
}
export default function CharacterStats({ scope }) {
const [stats, setStats] = useState(null)
useEffect(() => {
let cancelled = false
;(async () => {
try {
const accounts = await scope.accounts()
const linked = accounts.length
if (linked === 0) {
if (!cancelled) setStats({ linked: 0 })
return
}
// Roster is a live round-trip and can be unavailable (503); tolerate a
// partial result so a restarting shard doesn't blank the whole row.
const rosters = await Promise.allSettled(accounts.map((a) => scope.roster(a.account)))
if (!cancelled) setStats({ linked, ...summarizeRosters(rosters) })
} catch {
if (!cancelled) setStats({ error: true })
}
})()
return () => { cancelled = true }
}, [scope])
// Hidden until we know an account is linked (or while first loading).
if (!stats || stats.error || stats.linked === 0) return null
// Counts depend on live rosters; show a dash if none came back.
const count = (n) => (stats.complete || stats.chars > 0 ? n : '—')
return (
<section className="grid-3" style={{ gap: 14, marginBottom: 26 }}>
<Tile value={count(stats.chars)} label="Characters" />
<Tile value={count(stats.online)} label="Online now" />
<Tile value={stats.linked} label={stats.linked === 1 ? 'Linked account' : 'Linked accounts'} />
</section>
)
}

View File

@@ -1,69 +0,0 @@
import { useState } from 'react'
// Reusable "create a game account" form (its own username + password — the game
// client credentials, distinct from the website login). Calls `submit(account,
// password)` which should POST /player/shard/account; on success calls onCreated.
// Used by the player portal (self-serve) and the invite-accept page alike.
export default function CreateGameAccountForm({ submit, onCreated, compact = false }) {
const [account, setAccount] = useState('')
const [password, setPassword] = useState('')
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const [error, setError] = useState('')
async function onSubmit(e) {
e.preventDefault()
setMsg(''); setError('')
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{2,29}$/.test(account)) {
return setError('Account name must be 330 letters, numbers, . _ or -.')
}
if (password.length < 8) return setError('Password must be at least 8 characters.')
setBusy(true)
try {
await submit(account, password)
setMsg(`Game account “${account}” created and linked.`)
setAccount(''); setPassword('')
if (onCreated) await onCreated()
} catch (err) {
if (err.status === 409) setError('That account name is already taken.')
else if (err.status === 429) setError('The account limit for your network has been reached.')
else if (err.status === 403) setError('Game-account signup is not available right now.')
else if (err.status === 503) setError('The game server is unavailable — try again shortly.')
else setError(err.message || 'Could not create the account right now.')
} finally {
setBusy(false)
}
}
return (
<form onSubmit={onSubmit}>
{!compact && (
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
Choose the username and password youll type into the game client. These are your
<strong style={{ color: 'var(--head)' }}> game</strong> credentials separate from your website login.
</p>
)}
<label style={{ display: 'block', marginBottom: 14 }}>
<span className="field-label">Game account name</span>
<input
type="text" autoComplete="off" value={account}
onChange={(e) => setAccount(e.target.value)} className="input" placeholder="e.g. darrow"
/>
</label>
<label style={{ display: 'block', marginBottom: 16 }}>
<span className="field-label">Game password</span>
<input
type="password" autoComplete="new-password" value={password}
onChange={(e) => setPassword(e.target.value)} className="input"
/>
</label>
{error && <p className="sans" style={{ margin: '0 0 12px', color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>}
{msg && <p className="sans" style={{ margin: '0 0 12px', color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</p>}
<button type="submit" disabled={busy} className="btn btn-primary btn-sq">
{busy ? 'Creating…' : 'Create game account'}
</button>
</form>
)
}

View File

@@ -1,231 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Loading, ErrorState } from './PageState.jsx'
import ShardAccountActions from './ShardAccountActions.jsx'
import CreateGameAccountForm from './CreateGameAccountForm.jsx'
import { api } from '../api/client.js'
// Shared game-account linking + character roster, used by both the player portal
// (/player) and the staff account page (/admin/account). `scope` is the api
// object with { link, accounts, roster } (player or admin self-service); `charTo`
// maps a serial to the route for that character's sheet. `readOnly` drops the
// link forms and self-voice copy for the admin case where staff view *another*
// user's accounts (no `scope.link`) at /admin/users/:id.
function LinkForm({ scope, onLinked, compact }) {
const [code, setCode] = useState('')
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const [error, setError] = useState('')
async function submit(e) {
e.preventDefault()
setMsg(''); setError('')
if (!code.trim()) return
setBusy(true)
try {
const { account } = await scope.link(code.trim())
setMsg(`Linked ${account}.`)
setCode('')
await onLinked()
} catch (err) {
setError(err.message || 'Could not link that code.')
} finally {
setBusy(false)
}
}
return (
<form onSubmit={submit} style={{ display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap', marginTop: compact ? 0 : 6 }}>
<label style={{ display: 'block' }}>
{!compact && <span className="field-label">Link code</span>}
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())}
className="input"
autoComplete="off"
placeholder="AB12CD"
style={{ maxWidth: 180, textTransform: 'uppercase', letterSpacing: '0.12em' }}
/>
</label>
<button type="submit" disabled={busy || !code.trim()} className="btn btn-primary btn-sq">
{busy ? 'Linking…' : 'Link account'}
</button>
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
</form>
)
}
function AccountRoster({ scope, account, charTo }) {
const [roster, setRoster] = useState(null)
const [error, setError] = useState('')
const [unavailable, setUnavailable] = useState(false)
const load = useCallback(async () => {
setError(''); setUnavailable(false)
try {
setRoster(await scope.roster(account))
} catch (err) {
if (err.status === 503) setUnavailable(true)
else setError(err.message || 'Could not load this account.')
}
}, [scope, account])
useEffect(() => { load() }, [load])
if (unavailable) {
return (
<div>
<p className="sans" style={{ margin: '0 0 8px', color: '#e0b070', fontSize: '0.85rem' }}>The game server is restarting try again shortly.</p>
<button className="pill" onClick={load}>Retry</button>
</div>
)
}
if (error) return <p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>
if (!roster) return <p className="sans dim" style={{ margin: 0, fontSize: '0.82rem' }}>Loading</p>
const chars = roster.chars || []
if (chars.length === 0) return <p className="sans dim" style={{ margin: 0, fontSize: '0.84rem' }}>No characters on this account.</p>
return (
<div className="grid-2" style={{ gap: 12 }}>
{chars.map((c) => (
<Link
key={c.serial}
to={charTo(c.serial)}
style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '14px 16px', border: '1px solid var(--line)', borderRadius: 10, textDecoration: 'none', background: 'rgba(255,255,255,0.02)' }}
>
<span style={{ flex: 'none', width: 40, height: 40, borderRadius: '50%', background: 'linear-gradient(180deg,#2a3a52,#1a2536)', border: '1px solid var(--line)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#d8e2ef', fontSize: '1rem', textTransform: 'uppercase' }}>
{(c.name || '?').charAt(0)}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="display" style={{ color: 'var(--head)', fontSize: '1.02rem' }}>{c.name}</div>
<div className="sans" style={{ fontSize: '0.76rem', color: c.online ? '#7fd0a4' : 'var(--muted)' }}>{c.online ? 'Online' : 'Offline'}</div>
</div>
<span className="sans dim" style={{ fontSize: '1.1rem' }}></span>
</Link>
))}
</div>
)
}
// Compact per-account "Unlink" button for the admin (readOnly) view. Confirms,
// then calls onUnlink(account) and reloads. Errors surface inline.
function UnlinkButton({ account, onUnlink }) {
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
async function go() {
if (!window.confirm(`Unlink game account “${account}” from this user? Attribution stops immediately.`)) return
setBusy(true); setError('')
try {
await onUnlink(account)
} catch (err) {
const byStatus = { 403: 'Protected account — refused.', 404: 'Not linked.' }
setError(byStatus[err.status] || err.message || 'Could not unlink.')
setBusy(false)
}
}
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
<button type="button" onClick={go} disabled={busy} className="pill" style={{ fontSize: '0.72rem', color: '#d98b84', borderColor: '#5b2020' }}>
{busy ? 'Unlinking…' : 'Unlink'}
</button>
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.76rem' }}>{error}</span>}
</span>
)
}
export default function GameAccounts({ scope, charTo, readOnly = false, moderation = false, onUnlink = null }) {
const [accounts, setAccounts] = useState(null)
const [error, setError] = useState('')
// Whether the site currently offers game-account creation (public flag). Only
// relevant for the self-service (non-readOnly) view with a createAccount scope.
const [signupOk, setSignupOk] = useState(false)
const load = useCallback(async () => {
setError('')
try {
setAccounts(await scope.accounts())
} catch {
setError(readOnly ? 'Could not load this users game accounts.' : 'Could not load your game accounts.')
}
}, [scope, readOnly])
useEffect(() => { load() }, [load])
useEffect(() => {
if (readOnly || !scope.createAccount) return
let active = true
api.publicSettings()
.then((s) => active && setSignupOk(Boolean(s?.gameAccountSignup)))
.catch(() => {})
return () => { active = false }
}, [readOnly, scope])
const canCreate = !readOnly && Boolean(scope.createAccount) && signupOk
if (error) return <ErrorState message={error} />
if (!accounts) return <Loading />
// No linked accounts. In read-only (admin viewing another user) this is just an
// empty state; otherwise it's the link-your-account prompt.
if (accounts.length === 0) {
if (readOnly) {
return (
<div className="panel" style={{ padding: 22 }}>
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>
This user has not linked a game account.
</p>
</div>
)
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div className="panel" style={{ padding: 22 }}>
<div className="field-label" style={{ marginBottom: 8 }}>Link your game account</div>
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
Already play? In game, type <code style={{ color: 'var(--head)' }}>[link</code> to get a
one-time code, then enter it below to see your characters, stats, skills and vendors here.
</p>
<LinkForm scope={scope} onLinked={load} />
</div>
{canCreate && (
<div className="panel" style={{ padding: 22 }}>
<div className="field-label" style={{ marginBottom: 8 }}>Create a new game account</div>
<CreateGameAccountForm submit={scope.createAccount} onCreated={load} />
</div>
)}
</div>
)
}
// Linked — characters grouped by account.
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 26 }}>
{accounts.map((a) => (
<section key={a.account}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 12 }}>
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase' }}>
{a.account}
</div>
{onUnlink && <UnlinkButton account={a.account} onUnlink={async (acct) => { await onUnlink(acct); await load() }} />}
</div>
{moderation && <ShardAccountActions account={a.account} style={{ marginBottom: 12 }} />}
<AccountRoster scope={scope} account={a.account} charTo={charTo} />
</section>
))}
{!readOnly && (
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 20 }}>
<div className="field-label" style={{ marginBottom: 10 }}>Link another account</div>
<LinkForm scope={scope} onLinked={load} compact />
{canCreate && (
<div style={{ marginTop: 20 }}>
<div className="field-label" style={{ marginBottom: 10 }}>Create another game account</div>
<CreateGameAccountForm submit={scope.createAccount} onCreated={load} compact />
</div>
)}
</section>
)}
</div>
)
}

View File

@@ -2,7 +2,7 @@ import { useAuth } from '../contexts/AuthContext.jsx'
import { useSite } from '../contexts/SiteContext.jsx' import { useSite } from '../contexts/SiteContext.jsx'
import Maintenance from '../routes/public/Maintenance.jsx' import Maintenance from '../routes/public/Maintenance.jsx'
// Wraps the public site. When the shard is in maintenance, visitors see the // Wraps the public site. When the site is in maintenance, visitors see the
// coming-soon page; a logged-in admin sees the real site (live preview). // coming-soon page; a logged-in admin sees the real site (live preview).
export default function MaintenanceGate({ children }) { export default function MaintenanceGate({ children }) {
const { mode, loading } = useSite() const { mode, loading } = useSite()

View File

@@ -0,0 +1,150 @@
import { useEffect, useRef, useState } from 'react'
import { NavLink, useLocation } from 'react-router-dom'
// One dropdown section in the public header — a menu an admin created from
// Admin → Navigation (THEMING_AND_NAV.md §7, Phase 10).
//
// It **opens on click, never on hover**. Hover menus are unusable on touch, and
// the alternative (make the trigger a link too) means tapping to open navigates
// away instead. A section is a container, not a destination, so the trigger has
// no `to` at all.
//
// Everything else here is the keyboard and dismissal contract a menu needs:
// Escape closes and returns focus to the trigger, an outside press closes,
// navigating closes, and Arrow Up/Down walk the items. `aria-haspopup` +
// `aria-expanded` are what let a screen reader announce it as a menu rather than
// as a button that mysteriously changes the page.
export default function NavDropdown({ label, items, linkStyle }) {
const [open, setOpen] = useState(false)
const wrapRef = useRef(null)
const triggerRef = useRef(null)
const location = useLocation()
// The trigger shows the active treatment when the page you are on lives in
// this menu — otherwise entering a section makes the header look like nothing
// is selected.
const holdsActive = items.some((i) => (i.end ? location.pathname === i.to : location.pathname.startsWith(i.to)))
// Close on navigation. The menu is rendered inside a sticky header that
// survives route changes, so nothing else would dismiss it.
useEffect(() => setOpen(false), [location.pathname])
useEffect(() => {
if (!open) return undefined
const onKey = (e) => {
if (e.key !== 'Escape') return
setOpen(false)
triggerRef.current?.focus()
}
// `mousedown`, not `click`: closing on the press means a press that lands on
// another trigger opens that one in the same gesture.
const onOutside = (e) => {
if (!wrapRef.current?.contains(e.target)) setOpen(false)
}
document.addEventListener('keydown', onKey)
document.addEventListener('mousedown', onOutside)
return () => {
document.removeEventListener('keydown', onKey)
document.removeEventListener('mousedown', onOutside)
}
}, [open])
// Roving focus with the arrow keys, wrapping at both ends.
const onMenuKeyDown = (e) => {
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return
e.preventDefault()
const links = [...(wrapRef.current?.querySelectorAll('[data-menu-item]') || [])]
if (links.length === 0) return
const at = links.indexOf(document.activeElement)
const next = e.key === 'ArrowDown' ? (at + 1) % links.length : (at - 1 + links.length) % links.length
links[at === -1 ? 0 : next].focus()
}
return (
<div ref={wrapRef} style={{ position: 'relative' }} onKeyDown={onMenuKeyDown}>
<button
ref={triggerRef}
type="button"
className="pill"
aria-haspopup="true"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
...(holdsActive || open
? { background: 'var(--accent)', color: 'var(--bg-deep)', borderColor: 'var(--accent)' }
: {}),
}}
>
{label}
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .15s' }}
>
<path d="M6 9l6 6 6-6" />
</svg>
</button>
{open && (
<div
role="menu"
aria-label={label}
style={{
position: 'absolute',
top: 'calc(100% + 6px)',
left: 0,
minWidth: 190,
// The header wraps, so a menu near the right edge must not push the
// page sideways on a narrow screen.
maxWidth: 'calc(100vw - 24px)',
display: 'flex',
flexDirection: 'column',
gap: 2,
padding: 6,
borderRadius: 'var(--radius-card)',
border: '1px solid var(--line)',
background: 'var(--panel-flat)',
boxShadow: 'var(--shadow-card)',
zIndex: 40,
}}
>
{items.map((item) => (
<NavLink
key={item.kind === 'link' ? item.id : item.to}
to={item.to}
end={item.end}
role="menuitem"
data-menu-item=""
onClick={() => setOpen(false)}
className="sans"
style={({ isActive }) => ({
padding: '7px 10px',
borderRadius: 'var(--radius-input)',
fontSize: '0.85rem',
textDecoration: 'none',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
...linkStyle({ isActive }),
...(isActive ? {} : { color: 'var(--muted)' }),
})}
>
{item.label}
</NavLink>
))}
</div>
)}
</div>
)
}

View File

@@ -1,84 +0,0 @@
import { useMemo } from 'react'
import { useAsync } from '../lib/useAsync.js'
import { useShardFeed } from '../lib/useShardFeed.js'
import { bucketize } from '../data/regionBuckets.js'
import { api } from '../api/client.js'
// Compact live "Players Online" widget. Loads the presence.online aggregate once,
// then keeps the total + region breakdown current from the presence.online SSE
// kind. The raw byRegion map is rolled up into display buckets (see
// data/regionBuckets.js). NOT a page — drop it into any panel/column.
const PRESENCE_KINDS = new Set(['presence.online'])
export default function PlayersOnline() {
const { loading, error, data } = useAsync(() => api.shard.presence())
const { events } = useShardFeed({ filter: PRESENCE_KINDS, max: 4 })
// The freshest snapshot wins: the newest buffered presence.online event, else
// the initial fetch.
const snapshot = events[0] || data
const { total, rows } = useMemo(() => {
const count = Number(snapshot?.count) || 0
const { rows: bucketRows } = bucketize(snapshot?.byRegion)
return { total: count, rows: bucketRows }
}, [snapshot])
return (
<section className="panel" style={{ padding: 20 }}>
<div
className="sans"
style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}
>
<span
style={{
color: 'var(--accent)',
fontSize: '0.7rem',
letterSpacing: '0.12em',
textTransform: 'uppercase',
}}
>
Players online
</span>
<span className="display" style={{ fontSize: '1.5rem', color: 'var(--head)', lineHeight: 1 }}>
{loading ? '—' : total}
</span>
</div>
{error && (
<p className="sans dim" style={{ margin: '12px 0 0', fontSize: '0.84rem' }}>
Population is unavailable right now.
</p>
)}
{!loading && !error && (
<div style={{ marginTop: 14, display: 'flex', flexDirection: 'column', gap: 6 }}>
{rows.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.84rem' }}>
{total > 0 ? 'Locations are settling…' : 'The realm is quiet.'}
</p>
) : (
rows.map((r) => (
<div
key={r.id}
className="sans"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
fontSize: '0.9rem',
color: 'var(--ink)',
}}
>
<span>{r.label}</span>
{/* tabular figures keep the right-aligned counts in a clean column */}
<span className="dim" style={{ fontVariantNumeric: 'tabular-nums' }}>{r.count}</span>
</div>
))
)}
</div>
)}
</section>
)
}

View File

@@ -1,88 +0,0 @@
import { useState } from 'react'
import { useAuth } from '../contexts/AuthContext.jsx'
import { api } from '../api/client.js'
// Compact in-game moderation controls (kick / ban / unban) scoped to a single
// game account. Reused wherever a linked account or character is shown to staff:
// the admin user-detail account list and the character sheet. Self-gates on role
// (admin/moderator) so it is safe to render inside components that players also
// see — a player never gets the controls, and the API enforces the same gate.
//
// `actor` is stamped server-side from the session; nothing here sends it. Kick is
// reversible (they reconnect) so it acts immediately; Ban reveals an inline
// confirm with an optional duration + reason before it fires.
export default function ShardAccountActions({ account, style }) {
const { user } = useAuth()
const [busy, setBusy] = useState('')
const [ok, setOk] = useState('')
const [err, setErr] = useState('')
const [banOpen, setBanOpen] = useState(false)
const [durationSec, setDurationSec] = useState('')
const [reason, setReason] = useState('')
// Only staff who can actually use the write plane see the controls.
if (!user || !['admin', 'moderator'].includes(user.role) || !account) return null
async function run(label, fn, done) {
setBusy(label); setOk(''); setErr('')
try {
const r = await fn()
setOk(done(r))
} catch (e) {
setErr(e.message || 'Action failed.')
} finally {
setBusy('')
}
}
const kick = () =>
run('kick', () => api.admin.shardOps.kick({ account }), (r) => {
const n = r && r.sessions != null ? r.sessions : null
const plural = n === 1 ? '' : 's'
const sessions = n != null ? ` (${n} session${plural})` : ''
return `Kicked${sessions}.`
})
const unban = () => run('unban', () => api.admin.shardOps.unban(account), () => 'Unbanned.')
const ban = () =>
run('ban', () =>
api.admin.shardOps.ban({
account,
durationSec: durationSec === '' ? undefined : Number(durationSec),
reason: reason.trim() || undefined,
}),
() => {
setBanOpen(false)
const when = durationSec ? ` for ${durationSec}s` : ' indefinitely'
return `Banned${when}.`
})
const btn = { fontSize: '0.72rem', padding: '4px 10px' }
return (
<div className="sans" style={{ display: 'flex', flexDirection: 'column', gap: 8, ...style }}>
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8 }}>
<button onClick={kick} disabled={!!busy} className="btn btn-sq" style={btn}>{busy === 'kick' ? '…' : 'Kick'}</button>
<button onClick={() => { setBanOpen((v) => !v); setOk(''); setErr('') }} disabled={!!busy} className="btn btn-sq" style={{ ...btn, borderColor: '#d98b84', color: '#d98b84' }}>Ban</button>
<button onClick={unban} disabled={!!busy} className="btn btn-sq" style={btn}>{busy === 'unban' ? '…' : 'Unban'}</button>
{ok && <span style={{ color: '#7fd0a4', fontSize: '0.8rem' }}>{ok}</span>}
{err && <span style={{ color: '#d98b84', fontSize: '0.8rem' }}>{err}</span>}
</div>
{banOpen && (
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'flex-end', gap: 8, padding: '10px 12px', border: '1px solid var(--line)', borderRadius: 8, background: 'rgba(217,139,132,0.06)' }}>
<label style={{ display: 'block' }}>
<span className="field-label">Duration (sec, blank = permanent)</span>
<input type="number" value={durationSec} onChange={(e) => setDurationSec(e.target.value)} className="input" min={0} placeholder="604800" style={{ maxWidth: 150 }} />
</label>
<label style={{ display: 'block', flex: 1, minWidth: 160 }}>
<span className="field-label">Reason (optional)</span>
<input type="text" value={reason} onChange={(e) => setReason(e.target.value)} className="input" maxLength={500} placeholder="harassment" autoComplete="off" />
</label>
<button onClick={ban} disabled={busy === 'ban'} className="btn btn-primary btn-sq" style={{ borderColor: '#d98b84', background: '#d98b84', ...btn }}>
{busy === 'ban' ? 'Banning…' : `Confirm ban ${account}`}
</button>
</div>
)}
</div>
)
}

View File

@@ -1,5 +1,14 @@
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { useSite } from '../contexts/SiteContext.jsx' import { useSite } from '../contexts/SiteContext.jsx'
import Slot from '../modules/Slot.jsx'
const FOOTER_SLOT = 'site.footer.status'
// Handed to the extension rather than left for it to guess. A module rendering
// its own link in this row should look like the row, and the alternative is
// every module restating core's colours and then drifting from them the next
// time this footer is themed.
const LINK_STYLE = { color: 'var(--accent)', textDecoration: 'none' }
export default function SiteFooter() { export default function SiteFooter() {
const { contactEmail, siteTitle } = useSite() const { contactEmail, siteTitle } = useSite()
@@ -31,15 +40,19 @@ export default function SiteFooter() {
</span> </span>
</div> </div>
<div className="site-footer-info"> <div className="site-footer-info">
<span>{siteTitle} is an independent private shard project.</span> <span>{siteTitle} is an independent, privately-run game server.</span>
<span style={{ color: 'var(--dim)', fontSize: '0.84rem' }}> <span style={{ color: 'var(--dim)', fontSize: '0.84rem' }}>
<a href={`mailto:${contactEmail}`} style={{ color: 'var(--accent)', textDecoration: 'none' }}> <a href={`mailto:${contactEmail}`} style={{ color: 'var(--accent)', textDecoration: 'none' }}>
{contactEmail} {contactEmail}
</a> </a>
&nbsp;·&nbsp; {/* A module's spot in the footer, and core supplies only the
<Link to="/site/shard" style={{ color: 'var(--accent)', textDecoration: 'none' }}> position and the styling: the label, the target and whether
Shard Status anything renders at all are the module's (MODULE_API.md §3.7).
</Link> The separator goes through `wrap` rather than sitting beside the
slot, so it shares the extension's fate — no module installed and
a module whose link throws both render nothing here, rather than
the second leaving a stray middot behind. */}
<Slot name={FOOTER_SLOT} linkStyle={LINK_STYLE} wrap={(link) => <>&nbsp;·&nbsp;{link}</>} />
&nbsp;·&nbsp; &nbsp;·&nbsp;
<Link to="/admin/login" style={{ color: '#5d6b7d', textDecoration: 'none' }}> <Link to="/admin/login" style={{ color: '#5d6b7d', textDecoration: 'none' }}>
Admin Admin

View File

@@ -1,22 +1,37 @@
import { useMemo } from 'react'
import { Link, NavLink } from 'react-router-dom' import { Link, NavLink } from 'react-router-dom'
import MoonDot from './MoonDot.jsx' import MoonDot from './MoonDot.jsx'
import BrandLogo from './BrandLogo.jsx'
import { useAuth } from '../contexts/AuthContext.jsx' import { useAuth } from '../contexts/AuthContext.jsx'
import { useSite } from '../contexts/SiteContext.jsx' import { useSite } from '../contexts/SiteContext.jsx'
import NavDropdown from './NavDropdown.jsx'
import { buildPublicNav, pruneNav } from '../lib/navOverrides.js'
import { parseJsonSetting } from '../lib/settingsJson.js'
import { withModuleNav } from '../modules/nav.js'
import { useFeatureGate } from '../modules/features.jsx'
// One consistent top nav for the whole public site. Every page gets the same // One consistent top nav for the whole public site. Every page gets the same
// main links plus an auth-aware entry on the right (Sign in / My Account / Admin). // main links plus an auth-aware entry on the right (Sign in / My Account / Admin).
const NAV = [ //
// A row may carry a `feature`, naming a surface an installed module can disable
// or gate to a higher audience; it is hidden when this viewer cannot reach it,
// so we never render a link that would 403. No CORE row carries one today — the
// nine that did were UO and left with the client half in slice 3 — but the gate
// is not dead code: a module's rows join this list and bring their own flags,
// resolved by the module that registered them (modules/featureGate.js).
//
// Exported because Admin -> Navigation edits this list. It stays declared here,
// with this component as its owner: the editor may only relabel, reorder and
// hide what it finds, and `to`/`feature` are never its to change (§7). An
// installed module's rows join it in `withModuleNav` below — before the override
// merge, so an admin can edit those rows exactly as they edit these.
export const NAV = [
{ label: 'Home', to: '/', end: true }, { label: 'Home', to: '/', end: true },
{ label: 'News', to: '/site/news' }, { label: 'News', to: '/site/news' },
{ label: 'Screenshots', to: '/site/screenshots' }, { label: 'Screenshots', to: '/site/screenshots' },
{ label: 'Five on Friday', to: '/site/five-on-friday' }, { label: 'Five on Friday', to: '/site/five-on-friday' },
{ label: 'Newsletter', to: '/site/newsletter' }, { label: 'Newsletter', to: '/site/newsletter' },
{ label: 'Wiki', to: '/wiki' }, { label: 'Wiki', to: '/wiki' },
{ label: 'Shard', to: '/site/shard' },
{ label: 'Champions', to: '/site/champs' },
{ label: 'Guilds', to: '/site/guilds' },
{ label: 'Governors', to: '/site/governors' },
{ label: 'Houses', to: '/site/houses' },
{ label: 'About', to: '/site/about' }, { label: 'About', to: '/site/about' },
] ]
@@ -28,7 +43,29 @@ const linkStyle = ({ isActive }) => ({
export default function SiteHeader() { export default function SiteHeader() {
const { user, loading } = useAuth() const { user, loading } = useAuth()
const { siteTitle } = useSite() const { siteTitle, settings } = useSite()
const isVisible = useFeatureGate()
// Core's rows plus every installed module's. Computed once: the registry is
// fixed before the first render and there is no unregistering, so this cannot
// change during a session (modules/nav.js).
const baseNav = useMemo(() => withModuleNav(NAV, 'public'), [])
// An admin may relabel, reorder and hide these entries from Admin →
// Navigation, and may group them into dropdown sections alongside links of
// their own (THEMING_AND_NAV.md §7). Two things about the order here:
//
// • the override merge runs FIRST and the feature filter after it, so the
// filter stays the boundary — an override cannot un-hide a surface this
// viewer may not see, whatever it says. `pruneNav` applies the same
// check inside a section and drops one it leaves empty, so a dropdown
// never opens onto nothing;
// • with no stored row this is the coded NAV, in code order, so an
// untouched instance renders exactly what it renders today.
const nav = useMemo(() => {
const tree = buildPublicNav(baseNav, parseJsonSetting(settings.nav_public))
return pruneNav(tree, isVisible)
}, [baseNav, settings.nav_public, isVisible])
// Where the auth entry points: staff → admin, player → portal, else sign in. // Where the auth entry points: staff → admin, player → portal, else sign in.
let account let account
@@ -56,15 +93,20 @@ export default function SiteHeader() {
className="display" className="display"
style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: '1.2rem', letterSpacing: '0.05em', color: 'var(--accent-bright)', textDecoration: 'none', fontWeight: 600 }} style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: '1.2rem', letterSpacing: '0.05em', color: 'var(--accent-bright)', textDecoration: 'none', fontWeight: 600 }}
> >
<BrandLogo height={22} />
<MoonDot /> <MoonDot />
{siteTitle} {siteTitle}
</Link> </Link>
<nav style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}> <nav style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
{NAV.map((l) => ( {nav.map((l) =>
<NavLink key={l.to} to={l.to} end={l.end} className="pill" style={linkStyle}> l.kind === 'section' ? (
<NavDropdown key={l.id} label={l.label} items={l.items} linkStyle={linkStyle} />
) : (
<NavLink key={l.kind === 'link' ? l.id : l.to} to={l.to} end={l.end} className="pill" style={linkStyle}>
{l.label} {l.label}
</NavLink> </NavLink>
))} ),
)}
{!loading && ( {!loading && (
<NavLink <NavLink
to={account.to} to={account.to}

View File

@@ -1,41 +0,0 @@
import { useEffect, useState } from 'react'
import { ago } from '../lib/format.js'
// Owner-private recent player-vendor sales. `fetchSales` is the scope method
// (api.player.shard.sales / api.admin.shard.sales) — the server only returns
// sales for accounts linked to the caller.
export default function VendorSales({ fetchSales }) {
const [sales, setSales] = useState(null)
const [error, setError] = useState('')
useEffect(() => {
let active = true
fetchSales()
.then((rows) => active && setSales(rows))
.catch(() => active && setError('Could not load your vendor sales.'))
return () => { active = false }
}, [fetchSales])
if (error) return null
if (!sales) return null
return (
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
<div className="field-label" style={{ marginBottom: 12 }}>Recent vendor sales</div>
{sales.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No vendor sales recorded yet.</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
{sales.map((s) => (
<li key={`${s.t}-${s.itemType}-${s.price}`} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{s.itemType || 'An item'}{s.amount > 1 ? ` ×${s.amount}` : ''} {Number(s.price || 0).toLocaleString()}gp
</span>
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>{ago(s.t)}</span>
</li>
))}
</ul>
)}
</section>
)
}

View File

@@ -49,9 +49,11 @@ export function AuthProvider({ children }) {
}, []) }, [])
// Step 2 for SSO logins whose account has 2FA on. The pending challenge lives in // Step 2 for SSO logins whose account has 2FA on. The pending challenge lives in
// an httpOnly cookie, so only the code is sent. Returns { user, returnTo }. // an httpOnly cookie, so only the code is sent. `extra` carries the trustDevice/
const ssoLoginTotp = useCallback(async (code) => { // deviceName opt-in. Returns the full payload ({ user, returnTo,
const data = await api.ssoLoginTotp(code) // trustLimitReached?, devices? }) so the caller can handle the device-cap prompt.
const ssoLoginTotp = useCallback(async (code, extra) => {
const data = await api.ssoLoginTotp(code, extra)
setUser(data.user) setUser(data.user)
return data return data
}, []) }, [])

View File

@@ -1,5 +1,6 @@
import { createContext, useContext, useEffect, useState, useCallback, useMemo } from 'react' import { createContext, useContext, useEffect, useRef, useState, useCallback, useMemo } from 'react'
import { api } from '../api/client.js' import { api } from '../api/client.js'
import { applyThemeTokens } from '../lib/themeVars.js'
const SiteContext = createContext(null) const SiteContext = createContext(null)
@@ -7,11 +8,16 @@ const SiteContext = createContext(null)
export function SiteProvider({ children }) { export function SiteProvider({ children }) {
const [settings, setSettings] = useState({}) const [settings, setSettings] = useState({})
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
// Whether a fetch has actually SUCCEEDED, as distinct from `loading` — which
// also goes false when the request failed and we fell back to {}. The boot
// theme handoff below turns on this distinction.
const [settled, setSettled] = useState(false)
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
try { try {
const data = await api.publicSettings() const data = await api.publicSettings()
setSettings(data || {}) setSettings(data || {})
setSettled(true)
} catch { } catch {
setSettings({}) setSettings({})
} finally { } finally {
@@ -25,11 +31,38 @@ export function SiteProvider({ children }) {
const brand = useMemo(() => settings.brand || {}, [settings]) const brand = useMemo(() => settings.brand || {}, [settings])
// Apply the admin's theme. The whole effective token set is resolved
// server-side, so this only writes it and takes back what it wrote before —
// see lib/themeVars.js for why the removal half matters. No theme block means
// the admin never themed this instance, and the shipped :root stands.
const appliedTokens = useRef([])
useEffect(() => {
appliedTokens.current = applyThemeTokens(document.documentElement.style, settings.theme, appliedTokens.current)
// Take over from the shell's boot block. The server injects the same tokens
// into <head> so a themed instance does not paint the shipped palette for a
// frame first (utils/htmlShell.js); from here on this effect is the
// authority, and leaving the block behind would mean a later reset removed
// the inline properties only to reveal the stale block underneath.
//
// Gated on a SUCCESSFUL fetch, not merely a finished one: a failed request
// leaves us with no theme at all, and dropping the block then would strip a
// themed instance back to the shipped palette for no reason.
if (settled) document.getElementById('theme-boot')?.remove()
}, [settings.theme, settled])
// Apply the instance accent color to the CSS variable the theme is built on, // Apply the instance accent color to the CSS variable the theme is built on,
// so branding flows to every `var(--accent)` at runtime (no rebuild). // so branding flows to every `var(--accent)` at runtime (no rebuild). This is
// the *effective* accent — the admin theme overrides BRAND_ACCENT_COLOR
// server-side (docs/website/THEMING_AND_NAV.md §4.5) — so it agrees with the
// theme block rather than fighting it.
//
// Deliberately ordered after the theme effect and re-run on any theme change:
// resetting a theme removes --accent from the token map, and this has to be
// the write that lands last or an instance with a custom BRAND_ACCENT_COLOR
// would drop to the stylesheet's default accent until the next reload.
useEffect(() => { useEffect(() => {
if (brand.accent) document.documentElement.style.setProperty('--accent', brand.accent) if (brand.accent) document.documentElement.style.setProperty('--accent', brand.accent)
}, [brand.accent]) }, [brand.accent, settings.theme])
// Memoized so consumers don't re-render on every provider render (brand is a // Memoized so consumers don't re-render on every provider render (brand is a
// fresh object each render, which would otherwise churn the context value). // fresh object each render, which would otherwise churn the context value).

View File

@@ -1,31 +0,0 @@
// Placeholder heraldry for the eight City-Loyalty cities. Each entry is a simple
// emoji sigil + a ring colour — enough to make the Governors board and the
// governor badge read as distinct "crests" today, swappable for real artwork
// later WITHOUT touching any component: drop an `img` (an imported asset URL or a
// public path) onto an entry and update CityCrest to prefer it.
//
// Keyed by the exact `city` string the sidecar sends (see INTEGRATION.md §4:
// Moonglow, Britain, Jhelom, Yew, Minoc, Trinsic, SkaraBrae, NewMagincia).
export const CITY_CRESTS = {
Britain: { sigil: '⚜', color: '#c9a24b', label: 'Britain' },
Moonglow: { sigil: '🔮', color: '#7f8fd0', label: 'Moonglow' },
Minoc: { sigil: '⚒', color: '#b0763f', label: 'Minoc' },
Trinsic: { sigil: '⚓', color: '#5f9bd0', label: 'Trinsic' },
Yew: { sigil: '🌳', color: '#5fb98a', label: 'Yew' },
Jhelom: { sigil: '⚔', color: '#c76f6f', label: 'Jhelom' },
SkaraBrae: { sigil: '🐎', color: '#9a8bbf', label: 'Skara Brae' },
NewMagincia: { sigil: '🕊', color: '#cfc3a0', label: 'New Magincia' },
}
const FALLBACK = { sigil: '🏰', color: '#8c96a5', label: '' }
// Look up a crest by the raw city key, tolerating spacing variants
// ("Skara Brae" / "New Magincia"). `label` falls back to the given name.
export function crestFor(city) {
if (!city) return FALLBACK
const key = String(city).replace(/\s+/g, '')
const crest = CITY_CRESTS[city] || CITY_CRESTS[key]
if (crest) return crest
return { ...FALLBACK, label: String(city) }
}

View File

@@ -1,72 +0,0 @@
// Roll the sidecar's raw presence.online `byRegion` map (many named ServUO
// regions) up into a handful of labelled display buckets for the "Players Online"
// widget. This is the ONE place to retune the grouping — edit BUCKETS (order +
// membership) and the widget follows. Anything not matched lands in "Wilderness"
// so the bucket counts always reconcile to the true total.
// Named cities/towns, matched as a prefix on the (space/apostrophe-stripped)
// region name so "skara brae", "serpent's hold", etc. all resolve. Kept as a
// list rather than one giant alternation regex (simpler to read and retune).
const TOWN_PREFIXES = [
'moonglow', 'minoc', 'trinsic', 'jhelom', 'yew', 'skarabrae', 'magincia',
'newmagincia', 'vesper', 'nujelm', 'cove', 'ocllo', 'serpenthold', 'serpentshold',
'wind', 'delucia', 'papua',
]
const normalizeRegion = (r) => String(r).toLowerCase().replace(/['\s]/g, '')
// Ordered list of buckets. `label` shows in the widget; `match(region)` decides
// membership. First matching bucket wins; the last bucket is the catch-all.
export const BUCKETS = [
{
id: 'britain',
label: 'Britain',
// Passthrough for the capital + its immediate surrounds.
match: (r) => /^britain/i.test(r),
},
{
id: 'towns',
label: 'Towns',
// The other named cities/towns.
match: (r) => {
const norm = normalizeRegion(r)
return TOWN_PREFIXES.some((t) => norm.startsWith(t))
},
},
{
id: 'dungeons',
label: 'Dungeons',
match: (r) =>
/(despise|destard|deceit|shame|hythloth|covetous|wrong|terathan|fire|ice|orc cave|dungeon|abyss|doom|khaldun|wrong|blackthorn|exodus|labyrinth|underworld)/i.test(
r,
),
},
{
id: 'housing',
label: 'Housing',
// House regions expose themselves as named house/townhouse regions.
match: (r) => /(house|townhouse|homestead|tent)/i.test(r),
},
{
id: 'wilderness',
label: 'Wilderness',
// Catch-all: the unnamed "Wilderness" region + anything unmatched above.
match: () => true,
},
]
// Given a raw { region: count } map, return [{ id, label, count }] in BUCKETS
// order, dropping empty buckets, with the summed total also returned.
export function bucketize(byRegion = {}) {
const totals = new Map(BUCKETS.map((b) => [b.id, 0]))
let total = 0
for (const [region, n] of Object.entries(byRegion || {})) {
const count = Number(n) || 0
total += count
const bucket = BUCKETS.find((b) => b.match(String(region))) || BUCKETS[BUCKETS.length - 1]
totals.set(bucket.id, totals.get(bucket.id) + count)
}
const rows = BUCKETS.map((b) => ({ id: b.id, label: b.label, count: totals.get(b.id) })).filter(
(r) => r.count > 0,
)
return { rows, total }
}

100
client/src/lib/adminNav.js Normal file
View File

@@ -0,0 +1,100 @@
// Who may see a row of the admin sidebar, and where that lets them go.
//
// Plain JS, in its own file, for two reasons. It is shared — AdminLayout renders
// by it and Admin -> Navigation builds its palette by it (THEMING_AND_NAV.md
// §8.1), and a second copy of this answer is exactly the thing this file exists
// to abolish. And it is the closest thing in the client to an authorization
// decision, so it belongs somewhere the test runner can reach, which a .jsx file
// is not.
//
// **A row's own `roles` is the whole answer.** Until Phase 2 PR 8 this was
// `roles` AND a hardcoded `MOD_PATHS` list of five paths that confined
// moderators, AND a third prefix list in the redirect effect that disagreed with
// both (docs/website/MODULE_SYSTEM.md §1.4). A module's rows could never be
// added to a list core hardcodes, which is what forced the derivation — but the
// lists had already drifted from each other without a module in sight.
/**
* Can a viewer with this role see this row?
*
* Applied AFTER the override merge in both callers: an override is presentation
* and this is the boundary, so an override saying `hidden: false` on a row this
* role cannot see still shows nothing (THEMING_AND_NAV.md §7).
*
* A row with no `roles` is visible to everyone who reached the admin area at
* all — that is the self-service case (Account, My Characters), and staff are a
* superset of players.
*/
export function navItemVisibleTo(item, role) {
return !item.roles || item.roles.includes(role)
}
/**
* The paths a viewer with this role may reach, derived from the rows they see.
*
* Takes the BASE nav, never the override-merged one: an override must not be
* able to move this boundary in either direction. Hiding a row from a
* moderator's sidebar must not also bar them from the page behind it, and
* un-hiding one must not admit them to a page their role does not carry.
*
* @param {Array<{items: Array}>} baseNav the grouped admin nav
* @param {string} role
* @returns {Array<{to: string, exact: boolean}>}
*/
export function allowedPathsFor(baseNav, role) {
return (Array.isArray(baseNav) ? baseNav : [])
.flatMap((g) => g.items || [])
.filter((item) => navItemVisibleTo(item, role))
.map((item) => ({ to: item.to, exact: item.end === true }))
}
/**
* Is this pathname one of them?
*
* A row carrying `end` matches exactly — `/admin` is the dashboard, not a prefix
* of the whole admin area, and treating it as one would let every path through.
* Every other row also covers its sub-routes, which is what keeps
* `/admin/moderation/appeals/12` and a module's detail pages reachable without
* anyone listing them.
*/
export function isAllowedPath(pathname, allowed) {
return (allowed || []).some(({ to, exact }) =>
exact ? pathname === to : pathname === to || pathname.startsWith(`${to}/`),
)
}
/**
* The first place in this nav a viewer with this role can actually go.
*
* Added in Phase 3 slice 3, for the player portal, whose index route was
* `PlayerCharacters` — a UO page. When it left, `/player` had nothing behind it,
* and the three ways out were: redirect somewhere fixed, invent a core landing
* page, or resolve the index from the nav the viewer already has. This is the
* third, and it is the only one that keeps today's behaviour — with the module
* installed the first row is still Characters, so a player still lands on their
* characters after signing in, and with nothing installed they land on Account.
*
* **From the BASE nav, never the override-merged one**, the same rule
* `allowedPathsFor` follows and for a sharper version of the same reason: an
* override is presentation, and a landing page is behaviour. An admin reordering
* the sidebar must not silently change where everybody arrives, and — more to
* the point — must not be able to move it somewhere a role cannot follow.
*
* Deliberately generic, and deliberately in this file rather than in the portal
* layout. The admin area has the same shape of question (its index is a
* hardcoded Dashboard), and the direction of travel is one logged-in area that
* shows the right things for the viewer's permissions rather than two that
* duplicate each other. When that happens this is the function it needs, and it
* already answers for both nav shapes.
*
* @param {Array} baseNav flat or grouped, before overrides
* @param {string} role
* @param {string} fallback where to go when the viewer can see nothing at all
*/
export function firstDestinationFor(baseNav, role, fallback) {
const items = (Array.isArray(baseNav) ? baseNav : []).flatMap((entry) =>
entry && Array.isArray(entry.items) ? entry.items : [entry],
)
const first = items.find((item) => item && item.to && navItemVisibleTo(item, role))
return first ? first.to : fallback
}

View File

@@ -67,6 +67,11 @@ export function parseLayout(str) {
// The current hardcoded hero as a HeroLayout, so the page is unchanged until // The current hardcoded hero as a HeroLayout, so the page is unchanged until
// staff publish their own. Font sizes use the existing clamp() strings so the // staff publish their own. Font sizes use the existing clamp() strings so the
// default stays responsive (editor-created text uses px). // default stays responsive (editor-created text uses px).
//
// The copy is deliberately game-neutral, and deliberately still copy: this is
// also the starting point the hero editor loads, so an instance that wants to
// name its game says so there, once, and the result is stored — rather than core
// shipping one game's words for every instance to overwrite in source.
export function defaultLayout(teaser, name = 'Runic Gateway') { export function defaultLayout(teaser, name = 'Runic Gateway') {
return { return {
version: 1, version: 1,
@@ -84,9 +89,9 @@ export function defaultLayout(teaser, name = 'Runic Gateway') {
align: 'center', align: 'center',
width: 760, width: 760,
lines: [ lines: [
{ text: 'Private shard project', tag: 'span', fontSize: '0.74rem', color: '#c2d2e6', weight: 700, letterSpacing: '0.22em', transform: 'uppercase', font: 'sans' }, { text: 'Private game server', tag: 'span', fontSize: '0.74rem', color: '#c2d2e6', weight: 700, letterSpacing: '0.22em', transform: 'uppercase', font: 'sans' },
{ text: name, tag: 'h1', fontSize: 'clamp(3rem,8.5vw,5.75rem)', color: 'var(--head)', weight: 600, letterSpacing: '0.02em', lineHeight: 1, font: 'display', marginTop: 14 }, { text: name, tag: 'h1', fontSize: 'clamp(3rem,8.5vw,5.75rem)', color: 'var(--head)', weight: 600, letterSpacing: '0.02em', lineHeight: 1, font: 'display', marginTop: 14 },
{ text: 'A private Ultima Online world in progress', tag: 'p', fontSize: '1.32rem', color: '#dbe2ea', italic: true, marginTop: 22 }, { text: 'A private world in progress', tag: 'p', fontSize: '1.32rem', color: '#dbe2ea', italic: true, marginTop: 22 },
{ text: teaser, tag: 'div', html: true, fontSize: '1.06rem', color: '#c4cdd8', maxWidth: 600, marginTop: 22 }, { text: teaser, tag: 'div', html: true, fontSize: '1.06rem', color: '#c4cdd8', maxWidth: 600, marginTop: 22 },
], ],
}, },

View File

@@ -0,0 +1,506 @@
// Apply an admin's stored navigation overrides to a hardcoded NAV array.
//
// The three navs (public header, admin sidebar, player portal) stay declared in
// code; this layer only reorders, relabels and hides what is already there.
// See docs/website/THEMING_AND_NAV.md §7.
//
// **This is presentation, never authorization.** The override can carry
// `label`, `order`, `hidden` and — admin nav only — `group`, and nothing else.
// It cannot introduce a `to`, and it cannot touch `roles`, `feature`, `icon` or
// `end`, so the existing role/feature filters in SiteHeader and AdminLayout run
// *after* this merge, unchanged, and remain the actual boundary. An override
// saying `hidden: false` on a role-gated item still shows nothing to a viewer
// whose role check fails: hiding is subtractive here, never additive.
//
// Fail-safe throughout: anything unrecognized — an unknown `to`, a non-string
// label, a group that does not exist — is ignored rather than rejected, so a
// stale or hand-edited settings row degrades to the code default instead of
// rendering a broken nav.
// Two shapes are supported, because two exist:
// flat [{ to, label, ... }] — public header, player portal
// grouped [{ title?, items: [{ to, label, ... }] }] — admin sidebar
// Exported for modules/nav.js, which has to answer the same question about the
// same array a moment earlier — one implementation, so the interleave and the
// merge can never disagree about which shape they are looking at.
export function isGrouped(nav) {
return nav.length > 0 && nav.every((g) => g && Array.isArray(g.items))
}
// A stored override entry is usable only field by field: a bad `label` must not
// discard a good `order` alongside it.
function cleanEntry(raw, groupTitles) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
const out = {}
if (typeof raw.label === 'string' && raw.label.trim()) out.label = raw.label.trim()
if (typeof raw.order === 'number' && Number.isFinite(raw.order)) out.order = raw.order
if (raw.hidden === true) out.hidden = true
// `group` may only name a section the base nav already declares. Anything else
// — a renamed group, a typo, an invented category — is dropped, so an item can
// never land in a header that does not exist.
if (typeof raw.group === 'string' && groupTitles.has(raw.group)) out.group = raw.group
return out
}
// Sort by effective order, where an item the admin never reordered keeps its
// index in the base array as its key. Two tie-breaks, in order: an explicit
// order beats a coincidental index (the admin said "first", so first), and two
// explicit orders stay in code order (the sort is stable).
//
// In practice the editor writes an order for every item in a list, the way
// drag-and-drop reordering does, so ties are the stale-row case rather than the
// normal one. They still have to resolve predictably.
function byOrder(items) {
return items
.map((item, index) => ({ item, key: item.__order ?? index, explicit: item.__order !== undefined }))
.sort((a, b) => a.key - b.key || Number(b.explicit) - Number(a.explicit))
.map(({ item }) => {
const { __order, ...rest } = item
return rest
})
}
// Apply label/hidden/order to one flat list, with the sort key parked on
// `__order` for byOrder to consume.
//
// `keepHidden` is what the admin editor needs and the site must not have: the
// editor has to render a hidden row in its right place so it can be un-hidden,
// while a layout must simply not render it. Same merge either way, so the two
// can never disagree about where an item sits.
function mergeItems(items, entries, keepHidden = false) {
const out = []
for (const item of items) {
const o = entries.get(item.to)
if (o?.hidden && !keepHidden) continue
// Spread the base item first so `to`, `roles`, `feature`, `icon` and `end`
// survive verbatim — the override only ever lands on `label`.
out.push({
...item,
...(o?.label ? { label: o.label } : {}),
...(keepHidden ? { defaultLabel: item.label, hidden: o?.hidden === true } : {}),
__order: o?.order,
})
}
return out
}
// The stored overrides, cleaned and keyed, plus the group titles the base nav
// declares. Shared by the merge and the editor so both read a row the same way.
function readOverrides(baseNav, overrides, grouped) {
const groupTitles = new Set(
grouped ? baseNav.map((g) => g.title).filter((t) => typeof t === 'string') : [],
)
const entries = new Map()
if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) return { entries, groupTitles }
// Keyed by `to`, and only for a `to` the base nav actually declares. An
// override for a route that no longer exists is dropped here, so deleting a
// route in code can never leave a dangling override that does something
// unexpected later.
const known = new Set(
grouped ? baseNav.flatMap((g) => g.items.map((i) => i.to)) : baseNav.map((i) => i.to),
)
for (const [to, raw] of Object.entries(overrides)) {
if (!known.has(to)) continue
const entry = cleanEntry(raw, groupTitles)
if (entry && Object.keys(entry).length > 0) entries.set(to, entry)
}
return { entries, groupTitles }
}
// Move items whose override names a different existing section. Groups keep
// their coded order — only membership and within-group order move.
function regroup(baseNav, entries) {
const moved = new Map() // destination title → items pulled in from elsewhere
const kept = baseNav.map((g) => {
const items = []
for (const item of g.items) {
const o = entries.get(item.to)
if (o?.group && o.group !== g.title) {
if (!moved.has(o.group)) moved.set(o.group, [])
moved.get(o.group).push(item)
continue
}
items.push(item)
}
return { ...g, items }
})
return { kept, moved }
}
/**
* @param {Array} baseNav the hardcoded nav — the source of truth for `to`,
* `roles`, `feature`, `icon` and `end`
* @param {object|null} overrides the parsed settings JSON, keyed by `to`, or
* null when the admin never touched this nav
* @returns {Array} a new array of the same shape, or `baseNav` itself when there
* is nothing to apply
*/
export function applyNavOverrides(baseNav, overrides) {
if (!Array.isArray(baseNav)) return []
// The untouched path, and the one that matters most: no row, a malformed row,
// or a row with nothing usable in it all render the nav exactly as coded.
if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) return baseNav
const grouped = isGrouped(baseNav)
const { entries } = readOverrides(baseNav, overrides, grouped)
if (entries.size === 0) return baseNav
if (!grouped) return byOrder(mergeItems(baseNav, entries))
// Grouped: an item may also be moved into another *existing* titled section.
const { kept, moved } = regroup(baseNav, entries)
return kept
.map((g) => ({
...g,
items: byOrder(mergeItems([...g.items, ...(moved.get(g.title) || [])], entries)),
}))
// A group whose every item was hidden must not leave an orphaned header.
// AdminLayout drops empty groups again after its own role filter; doing it
// here too keeps the util correct on its own.
.filter((g) => g.items.length > 0)
}
// ── The admin editor's round trip ────────────────────────────────────────
//
// Two functions, inverse to each other, kept in this file rather than beside the
// editor screen so the thing that *writes* an override and the thing that
// *applies* one can never drift: the rows the admin drags are produced by the
// same merge the site renders, hidden ones included.
/**
* The base nav plus its stored overrides, as editable rows — always in the
* grouped shape, so one editor handles both navs.
*
* Unlike applyNavOverrides this keeps hidden rows (marked `hidden: true`, so
* they can be un-hidden) and keeps empty groups (so something can be moved back
* into one). Each row carries `defaultLabel`, which is what "reset this label"
* restores and what the input shows as its placeholder.
*
* @param {Array} baseNav the hardcoded nav, flat or grouped
* @param {object|null} overrides the parsed settings JSON
* @returns {Array<{title: string|null, items: Array}>}
*/
export function buildNavRows(baseNav, overrides) {
if (!Array.isArray(baseNav) || baseNav.length === 0) return []
const grouped = isGrouped(baseNav)
const { entries } = readOverrides(baseNav, overrides, grouped)
if (!grouped) {
return [{ title: null, items: byOrder(mergeItems(baseNav, entries, true)) }]
}
const { kept, moved } = regroup(baseNav, entries)
return kept.map((g) => ({
...g,
title: g.title ?? null,
items: byOrder(mergeItems([...g.items, ...(moved.get(g.title) || [])], entries, true)),
}))
}
// Did the admin actually move anything? Comparing the edited sequence with the
// coded one is what decides whether orders are written at all: an admin who only
// renamed an item should not pin the position of every other one, or a route
// added in code later would land in an arbitrary place.
//
// The base side is restricted to the rows the editor is actually holding: §8.1
// filters the palette to what this admin can themselves see, and an item that
// their role or a module's feature gate kept off the screen is not a reorder.
function orderMatchesBase(groups, baseNav) {
const flatten = (gs) => gs.flatMap((g) => g.items.map((i) => `${g.title ?? ''}::${i.to}`))
const base = isGrouped(baseNav)
? baseNav.map((g) => ({ title: g.title ?? null, items: g.items }))
: [{ title: null, items: baseNav }]
const shown = new Set(groups.flatMap((g) => g.items.map((i) => i.to)))
const a = flatten(groups)
const b = flatten(base.map((g) => ({ ...g, items: g.items.filter((i) => shown.has(i.to)) })))
return a.length === b.length && a.every((v, i) => v === b[i])
}
/**
* The rows the admin has been editing, back as an overrides object to store.
* Only differences from the code default are written — a field that matches the
* default is absent, so the row stays a small statement of intent rather than a
* snapshot of the nav.
*
* @param {Array} groups the editor's groups, in their current order
* @param {Array} baseNav the hardcoded nav these rows came from
* @param {object|null} stored the overrides as loaded, so entries for items
* this admin could not see (role- or feature-gated out of their palette) are
* carried through rather than silently dropped on save
* @returns {object} the overrides to store — `{}` when nothing differs
*/
export function buildNavOverrides(groups, baseNav, stored = null) {
if (!Array.isArray(groups) || !Array.isArray(baseNav)) return {}
const grouped = isGrouped(baseNav)
const baseItems = new Map(
(grouped ? baseNav.flatMap((g) => g.items.map((i) => [i, g.title ?? null])) : baseNav.map((i) => [i, null])).map(
([item, title]) => [item.to, { label: item.label, group: title }],
),
)
const out = {}
// Carry through what this admin's palette never showed them. An entry for a
// `to` the base nav no longer declares is NOT carried: dropping it is the
// cleanup, and applyNavOverrides ignores it anyway.
const shown = new Set(groups.flatMap((g) => g.items.map((i) => i.to)))
if (stored && typeof stored === 'object' && !Array.isArray(stored)) {
for (const [to, entry] of Object.entries(stored)) {
if (!shown.has(to) && baseItems.has(to) && entry && typeof entry === 'object') out[to] = entry
}
}
const writeOrder = !orderMatchesBase(groups, baseNav)
for (const group of groups) {
group.items.forEach((row, index) => {
const base = baseItems.get(row.to)
if (!base) return
const entry = {}
const label = typeof row.label === 'string' ? row.label.trim() : ''
if (label && label !== base.label) entry.label = label
if (row.hidden === true) entry.hidden = true
if (grouped && (group.title ?? null) !== base.group && group.title) entry.group = group.title
if (writeOrder) entry.order = index
if (Object.keys(entry).length > 0) out[row.to] = entry
})
}
return out
}
// ── The public header: dropdown sections and added links ────────────────
//
// Phase 10. The public nav is the one nav an admin can restructure rather than
// only reorder: they may create dropdown **sections**, drop coded entries into
// them, and add **links** of their own to pages on this site.
//
// The invariant §7 rests on survives, and it survives structurally rather than
// by vigilance: coded entries stay keyed by a `to` the base array must declare,
// so an override still cannot invent a route or touch a `roles`/`feature` gate,
// while everything that CAN name an arbitrary path lives in `links` where the
// path rule is applied. An added link carries no gate of its own and needs none
// — the page behind it enforces its own access, so a link to somewhere the
// viewer cannot reach 403s exactly as typing the URL would.
//
// Stored shape (server/src/utils/navOverrides.js is the writer):
// { items: {"<to>": {...}}, sections: [{id,label,order}], links: [{id,label,to,order,section}] }
// A bare map is still read as the items map — unambiguous, because every item
// key is a path and so can never be the string `items`.
function unwrapPublic(overrides) {
if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) {
return { items: {}, sections: [], links: [] }
}
const wrapped = overrides.items && typeof overrides.items === 'object' && !Array.isArray(overrides.items)
const items = wrapped ? overrides.items : overrides
const sections = wrapped && Array.isArray(overrides.sections) ? overrides.sections : []
const links = wrapped && Array.isArray(overrides.links) ? overrides.links : []
return { items, sections, links }
}
// Forgiving, like every other read here: an entry that is not usable is dropped
// and its neighbours kept.
function readSections(sections) {
const out = []
const seen = new Set()
for (const s of sections) {
if (!s || typeof s !== 'object' || typeof s.id !== 'string' || seen.has(s.id)) continue
if (typeof s.label !== 'string' || !s.label.trim()) continue
seen.add(s.id)
out.push({ id: s.id, label: s.label.trim(), order: typeof s.order === 'number' && Number.isFinite(s.order) ? s.order : undefined })
}
return out
}
function readLinks(links, knownSections) {
const out = []
const seen = new Set()
for (const l of links) {
if (!l || typeof l !== 'object' || typeof l.id !== 'string' || seen.has(l.id)) continue
if (typeof l.label !== 'string' || !l.label.trim()) continue
// Same rule the server writes by. A stored value that would leave the origin
// is dropped rather than rendered, so a hand-edited row cannot put an
// off-site link in the header.
if (typeof l.to !== 'string' || !l.to.startsWith('/') || l.to.startsWith('//') || /[\s<>"'\\]/.test(l.to)) continue
seen.add(l.id)
out.push({
id: l.id,
label: l.label.trim(),
to: l.to,
order: typeof l.order === 'number' && Number.isFinite(l.order) ? l.order : undefined,
section: typeof l.section === 'string' && knownSections.has(l.section) ? l.section : null,
})
}
return out
}
/**
* The public nav as a one-level tree of `{kind: 'item' | 'link' | 'section'}`.
*
* @param {Array} baseNav the hardcoded public NAV — still the only source of
* `to`, `feature` and `end` for a coded entry
* @param {object|null} overrides the parsed nav_public row
* @param {{keepHidden?: boolean}} [opts] the editor keeps hidden entries so
* they can be un-hidden, and gets `defaultLabel` for the reset affordance;
* the header must not render them at all
* @returns {Array}
*/
export function buildPublicNav(baseNav, overrides, { keepHidden = false } = {}) {
if (!Array.isArray(baseNav)) return []
const { items, sections: rawSections, links: rawLinks } = unwrapPublic(overrides)
const sections = readSections(rawSections)
const knownSections = new Set(sections.map((s) => s.id))
const links = readLinks(rawLinks, knownSections)
// Coded entries, keyed by a `to` the base array declares. Anything else in the
// map is dropped here, exactly as in applyNavOverrides.
const known = new Set(baseNav.map((i) => i.to))
const entries = new Map()
for (const [to, raw] of Object.entries(items)) {
if (!known.has(to)) continue
const entry = cleanEntry(raw, new Set())
if (!entry) continue
if (typeof raw?.section === 'string' && knownSections.has(raw.section)) entry.section = raw.section
entries.set(to, entry)
}
const nodes = []
baseNav.forEach((item, index) => {
const o = entries.get(item.to)
if (o?.hidden && !keepHidden) return
nodes.push({
kind: 'item',
...item,
...(o?.label ? { label: o.label } : {}),
...(keepHidden ? { defaultLabel: item.label, hidden: o?.hidden === true } : {}),
section: o?.section ?? null,
__order: o?.order,
__index: index,
})
})
// An admin-created entity with no stored order appends after the coded ones,
// in creation order, rather than jumping to the front on a 0 default.
let next = baseNav.length
for (const section of sections) {
nodes.push({ kind: 'section', id: section.id, label: section.label, section: null, __order: section.order, __index: next++ })
}
for (const link of links) {
nodes.push({ kind: 'link', id: link.id, to: link.to, label: link.label, section: link.section, __order: link.order, __index: next++ })
}
const place = (list) =>
list
.map((n) => ({ n, key: n.__order ?? n.__index, explicit: n.__order !== undefined }))
.sort((a, b) => a.key - b.key || Number(b.explicit) - Number(a.explicit))
.map(({ n }) => {
const { __order, __index, section, ...rest } = n
return rest
})
const top = place(nodes.filter((n) => n.kind === 'section' || !n.section))
return top.map((node) =>
node.kind === 'section'
? { ...node, items: place(nodes.filter((n) => n.section === node.id)) }
: node,
)
}
/**
* Apply the caller's visibility gate — and drop a section it leaves empty.
*
* Kept here rather than in SiteHeader because the empty-dropdown case is the one
* with real correctness risk: a section whose every entry is hidden by a
* module's visibility rules must not render as a menu that opens onto nothing.
* The predicate stays the caller's, so this module still knows nothing about
* what any module gates on.
*
* Added links carry no gate, so they are always visible — see the note above.
*
* @param {Array} tree from buildPublicNav
* @param {(item: object) => boolean} isVisible applied to coded items only
* @returns {Array}
*/
export function pruneNav(tree, isVisible) {
if (!Array.isArray(tree)) return []
const keep = (node) => node.kind !== 'item' || isVisible(node)
return tree
.map((node) => (node.kind === 'section' ? { ...node, items: (node.items || []).filter(keep) } : node))
.filter((node) => (node.kind === 'section' ? node.items.length > 0 : keep(node)))
}
/**
* The editor's tree back as a nav_public value to store.
*
* Returns the **bare items map** when there are no sections and no added links,
* so a nav that does not use this feature stores exactly what phases 6-8 stored.
*
* @param {Array} tree the editor's current tree
* @param {Array} baseNav the hardcoded public NAV
* @param {object|null} stored as loaded, so an entry for a feature-gated item
* this admin could not see survives their save
* @returns {object} `{}` when nothing differs from the code default
*/
export function buildPublicNavOverrides(tree, baseNav, stored = null) {
if (!Array.isArray(tree) || !Array.isArray(baseNav)) return {}
const baseLabels = new Map(baseNav.map((i) => [i.to, i.label]))
const sections = []
const links = []
const items = {}
// Flatten to (node, containerId, indexInContainer), which is all the writer
// needs: a section's own position is its index in the top-level list.
const placed = []
tree.forEach((node, index) => {
placed.push({ node, section: null, index })
if (node.kind === 'section') (node.items || []).forEach((child, i) => placed.push({ node: child, section: node.id, index: i }))
})
// Orders are written whenever this nav has any structure of its own: a section
// exists only because the admin put it somewhere, so its position is never
// "whatever the code says". Without sections the rule is phase 6-8's — write
// orders only if the sequence actually moved.
const hasStructure = tree.some((n) => n.kind === 'section' || n.kind === 'link')
const shown = new Set(tree.flatMap((n) => (n.kind === 'section' ? (n.items || []) : [n])).filter((n) => n.kind === 'item').map((n) => n.to))
const sequence = tree.filter((n) => n.kind === 'item').map((n) => n.to)
const baseSequence = baseNav.filter((i) => shown.has(i.to)).map((i) => i.to)
const moved = sequence.length !== baseSequence.length || sequence.some((to, i) => to !== baseSequence[i])
const writeOrder = hasStructure || moved
for (const { node, section, index } of placed) {
if (node.kind === 'section') {
sections.push({ id: node.id, label: (node.label || '').trim() || 'Section', ...(writeOrder ? { order: index } : {}) })
continue
}
if (node.kind === 'link') {
links.push({
id: node.id,
label: (node.label || '').trim() || node.to,
to: node.to,
...(section ? { section } : {}),
...(writeOrder ? { order: index } : {}),
})
continue
}
const entry = {}
const label = typeof node.label === 'string' ? node.label.trim() : ''
if (label && label !== baseLabels.get(node.to)) entry.label = label
if (node.hidden === true) entry.hidden = true
if (section) entry.section = section
if (writeOrder) entry.order = index
if (Object.keys(entry).length > 0) items[node.to] = entry
}
// Carry through an entry for a coded item this admin's palette never showed
// them (feature-gated by its module), so their save does not silently reset it.
const { items: storedItems } = unwrapPublic(stored)
for (const [to, entry] of Object.entries(storedItems)) {
if (!shown.has(to) && baseLabels.has(to) && entry && typeof entry === 'object') items[to] = entry
}
if (sections.length === 0 && links.length === 0) return items
const out = { items }
if (sections.length) out.sections = sections
if (links.length) out.links = links
return out
}
export default applyNavOverrides

View File

@@ -0,0 +1,32 @@
// Parse a JSON-valued settings row, client side.
//
// The counterpart to server/src/utils/settingsJson.js, and deliberately the same
// three lines of judgement: `settings.value` is TEXT, so theme_visual,
// brand_assets and the three nav_* keys all arrive as strings, and a malformed
// or wrong-shaped one must read as **absent** — the surface falls back to its
// BRAND_* env / theme.css / hardcoded NAV default — never as an error and never
// as a half-applied object.
//
// THEMING_AND_NAV.md §4.4 planned this "with its first consumer"; that consumer
// is the public header reading nav_public. `parseLayout` in heroLayout.js keeps
// its own version check because it validates a shape, not just a shape's kind.
/**
* @param {string|null|undefined} str the raw stored value
* @returns {object|null} the parsed object, or null when absent/malformed
*/
export function parseJsonSetting(str) {
if (typeof str !== 'string' || str === '') return null
let parsed
try {
parsed = JSON.parse(str)
} catch {
return null
}
// Only plain objects. A stored `null`, `4`, `"x"` or array is as unusable to
// every consumer of these keys as a syntax error is.
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
return parsed
}
export default parseJsonSetting

View File

@@ -1,128 +0,0 @@
// Shared formatting for shard events — used by the public Shard page, the
// Activity feed, and the admin live feed. One place decides how each kind reads
// and which category/badge it belongs to.
function nameOf(who) {
if (!who) return 'Someone'
if (typeof who === 'string') return who
return who.name || who.acct || 'Someone'
}
const n = (v) => Number(v || 0).toLocaleString()
// A one-line human description of each event kind, keyed by kind. Each formatter
// takes the payload and returns a string. Conditional suffixes are pulled into
// locals so no template literal is nested inside another.
const DESCRIBERS = {
'vendor.sale': (p) => {
const qty = p.amount > 1 ? ` ×${p.amount}` : ''
return `${p.itemType || 'An item'}${qty} sold for ${n(p.price)}gp`
},
'player.death': (p) => {
const by = p.killer ? ` by ${nameOf(p.killer)}` : ''
return `${nameOf(p.who)} was slain${by}`
},
'player.murdered': (p) => {
const by = p.murderer ? ` by ${nameOf(p.murderer)}` : ''
return `${nameOf(p.victim)} was murdered${by}`
},
'mob.killed': (p) => `${nameOf(p.killer)} killed ${nameOf(p.killed)}`,
'skill.gain': (p) => {
const base = p.base != null ? ` (${p.base})` : ''
return `${nameOf(p.who)} gained ${p.skill}${base}`
},
'fame.change': (p) => `${nameOf(p.who)}s fame changed to ${n(p.new)}`,
'karma.change': (p) => `${nameOf(p.who)}s karma changed to ${n(p.new)}`,
'quest.complete': (p) => `${nameOf(p.who)} completed “${p.quest}`,
'house.decay': (p) => {
const region = p.region ? `${p.region}` : ''
return `${p.name || 'A house'} is now ${p.to || p.stage}${region}`
},
'mob.login': (p) => `${nameOf(p.who)} entered the world`,
'mob.logout': (p) => `${nameOf(p.who)} left the world`,
'economy.supply': (p) => `Gold supply: ${n(p.gold)} across ${n(p.accounts)} accounts`,
'server.hello': (p) => `Shard online — ${n(p.accounts)} accounts, ${n(p.mobiles)} mobiles`,
'server.shutdown': () => 'Shard shut down',
'server.crashed': (p) => {
const err = p.error ? `: ${p.error}` : ''
return `Shard crashed${err}`
},
'champ.update': (p) => {
const where = p.name || p.type || 'A champion spawn'
if (p.status === 'active' && p.bossUp) {
const boss = p.boss ? ` (${p.boss})` : ''
return `${where}: boss is up${boss}`
}
if (p.status === 'active') {
const level = p.level != null ? ` — level ${p.level}` : ''
return `${where} is active${level}`
}
if (p.status === 'cooldown') return `${where} is on cooldown`
return `${where} is ${p.status || 'idle'}`
},
'champ.remove': () => `A champion spawn ended`,
// Support (help-page) queue + in-game moderation (admin channel only)
'page.new': (p) => `New ${p.type || 'help'} page from ${nameOf(p.sender)}`,
'page.updated': (p) => {
const claimed = p.handled ? ' (claimed)' : ''
return `Help page from ${nameOf(p.sender)} updated${claimed}`
},
'page.closed': (p) => `Help page ${p.pageId || ''} closed`,
'admin.audit': (p) => {
const on = p.target ? ` on ${p.target}` : ''
const origin = p.origin ? ` [${p.origin}]` : ''
return `${p.actor || 'Staff'} ${p.action || 'acted'}${on}${origin}`
},
// Staff / sensitive (admin channel only)
'audit.set': (p) =>
`${nameOf(p.staff) || 'Staff'} set ${p.prop} on ${p.target || p.targetSerial} (${p.old}${p.new})`,
'audit.command': (p) => {
const args = p.args ? ` ${p.args}` : ''
return `${nameOf(p.staff) || 'Staff'} ran ${p.command}${args}`
},
'cheat.fastwalk': (p) => {
const ip = p.ip ? ` (${p.ip})` : ''
return `Fast-walk flagged: ${nameOf(p.who)}${ip}`
},
'account.login.attempt': (p) => {
const ip = p.ip ? ` from ${p.ip}` : ''
return `Login attempt: ${p.acct}${ip}`
},
'gold.change': (p) => {
const sign = p.delta >= 0 ? '+' : ''
return `${p.acct}: gold ${sign}${n(p.delta)}${n(p.new)}`
},
}
// A one-line human description of an event. Accepts either a stored event
// (with .payload) or a raw live frame (fields at top level).
export function describe(ev) {
const fmt = DESCRIBERS[ev.kind]
return fmt ? fmt(ev.payload || ev) : ev.kind
}
// Category grouping for the filter tabs.
// Vendor sales are intentionally NOT a public category — they are owner-private
// (a linked player sees their own under the portal). The admin live feed still
// describes vendor.sale via describe() below.
export const CATEGORIES = [
{ id: 'all', label: 'All', kinds: null },
{ id: 'pvp', label: 'Deaths & PvP', kinds: ['player.death', 'player.murdered', 'mob.killed'] },
{ id: 'progress', label: 'Progression', kinds: ['skill.gain', 'fame.change', 'karma.change', 'quest.complete'] },
{ id: 'world', label: 'World', kinds: ['house.decay', 'mob.login', 'mob.logout', 'server.hello', 'server.shutdown', 'server.crashed', 'economy.supply'] },
]
const CATEGORY_OF = (() => {
const m = {}
for (const c of CATEGORIES) if (c.kinds) for (const k of c.kinds) m[k] = c.id
return m
})()
export function categoryOf(kind) {
return CATEGORY_OF[kind] || 'other'
}
// Short badge label for a kind (the part after the dot, title-cased-ish).
export function kindLabel(kind) {
return String(kind || '').replace(/[._]/g, ' ')
}

View File

@@ -0,0 +1,47 @@
// Apply the server-resolved theme to the document as CSS custom properties.
//
// The effective token set is resolved server-side and arrives on
// `settings.theme` (see server/src/utils/themeResolve.js). The client's only
// job is to write it onto <html> — and, crucially, to take back what it wrote
// last time, which is the part with actual logic and the reason this lives in
// its own testable module.
//
// Why removal matters: an admin who resets the theme, or switches from a preset
// that sets --bg to one that does not, gets a payload that no longer mentions
// that variable. Inline properties are not cleared by writing a smaller object
// over them, so without an explicit removeProperty the old value would stick
// until a reload. That would make "Reset to defaults" look broken.
//
// Everything written here is a value the server validated against a closed set
// (hex color, curated font stack, bounded px length, listed shadow). The client
// deliberately does not re-validate — it would be a second, drifting authority.
// It does refuse anything that is not a `--custom-property`, which is the one
// check that costs nothing and stops a token map from reaching an ordinary CSS
// property.
const CUSTOM_PROPERTY = /^--[a-zA-Z0-9-_]+$/
/**
* @param {CSSStyleDeclaration} style usually document.documentElement.style
* @param {Record<string, string>|null|undefined} tokens the new theme, or
* null/absent for "no admin theme" — which clears everything previously set
* @param {string[]} [applied] the keys this function wrote last time
* @returns {string[]} the keys now applied, to pass back on the next call
*/
export function applyThemeTokens(style, tokens, applied = []) {
const next = []
if (tokens && typeof tokens === 'object') {
for (const [name, value] of Object.entries(tokens)) {
if (!CUSTOM_PROPERTY.test(name) || typeof value !== 'string' || value === '') continue
style.setProperty(name, value)
next.push(name)
}
}
// Take back only what we set ourselves. Anything else on the element's inline
// style belongs to someone else (SiteContext's own --accent line, a future
// feature) and is not ours to clear.
for (const name of applied) {
if (!next.includes(name)) style.removeProperty(name)
}
return next
}

View File

@@ -0,0 +1,56 @@
import { useEffect, useState } from 'react'
import { api } from '../api/client.js'
import { parseJsonSetting } from './settingsJson.js'
// The nav overrides for the two authenticated layouts (THEMING_AND_NAV.md §4.2).
//
// `nav_public` rides along in the public settings payload, but `nav_admin` and
// `nav_player` deliberately do not: an anonymous visitor has no use for either,
// and the admin nav's labels describe the shape of the admin surface. Their
// owners read them from GET /api/v1/settings/nav, which any signed-in account
// may call — AdminLayout renders for editors and moderators, who cannot reach
// GET /admin/settings at all.
//
// Failing quiet is the whole posture: a request that errors, a malformed row and
// "not fetched yet" are the same state to the caller, `{}`, which
// applyNavOverrides turns into the coded nav. A sidebar must never blink empty
// because a settings call was slow.
// One module-level copy, so the second layout to mount renders the nav it
// already knows rather than flashing the coded one, and so the nav editor can
// push its save into the sidebar the admin is looking at without a reload.
let cache = {}
const subscribers = new Set()
async function load() {
try {
const data = await api.navSettings()
cache = {
nav_admin: parseJsonSetting(data?.nav_admin),
nav_player: parseJsonSetting(data?.nav_player),
}
subscribers.forEach((fn) => fn(cache))
} catch {
/* the coded nav is the fallback, and it is already on screen */
}
return cache
}
/** Re-read the rows after a save, so the live sidebar catches up at once. */
export function refreshNavOverrides() {
return load()
}
export function useNavOverrides() {
const [overrides, setOverrides] = useState(cache)
useEffect(() => {
subscribers.add(setOverrides)
load()
return () => subscribers.delete(setOverrides)
}, [])
return overrides
}
export default useNavOverrides

View File

@@ -1,54 +0,0 @@
import { useEffect, useRef, useState } from 'react'
import { api } from '../api/client.js'
// Subscribe to the public shard live-event SSE stream and keep a rolling buffer
// of the most recent events. The browser talks to our own /public/shard/stream
// route (plain HTTP EventSource) — never the sidecar's WebSocket — so the token
// stays server-side and it works through any reverse proxy.
//
// EventSource auto-reconnects on drop, so there is no manual retry loop here; a
// `connected` flag is exposed for a small live/offline indicator. `filter` (a
// Set of kinds, optional) limits which events are buffered. `max` caps the
// buffer length.
export function useShardFeed({ url, filter, max = 40 } = {}) {
const [events, setEvents] = useState([])
const [connected, setConnected] = useState(false)
// Keep the latest filter in a ref so re-renders don't tear down the stream.
const filterRef = useRef(filter)
filterRef.current = filter
const streamUrl = url || api.shardStreamUrl
useEffect(() => {
// EventSource isn't available during SSR / very old browsers — degrade to
// "no live feed" rather than throwing.
if (typeof window === 'undefined' || typeof window.EventSource === 'undefined') return undefined
const es = new EventSource(streamUrl, { withCredentials: true })
es.onopen = () => setConnected(true)
es.onerror = () => setConnected(false) // EventSource will retry on its own
es.onmessage = (msg) => {
let event
try {
event = JSON.parse(msg.data)
} catch {
return
}
if (!event || !event.kind) return
const f = filterRef.current
if (f && !f.has(event.kind)) return
setEvents((prev) => {
// Tag with a stable-ish local id for React keys (events carry t but can
// collide within a ms) and cap the buffer.
const next = [{ ...event, _id: `${event.kind}-${event.t}-${prev.length}` }, ...prev]
return next.slice(0, max)
})
}
return () => es.close()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [max, streamUrl])
return { events, connected }
}

View File

@@ -2,12 +2,97 @@ import React from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom' import { BrowserRouter } from 'react-router-dom'
import App from './App.jsx' import App from './App.jsx'
import { publishSharedDependencies } from './modules/shared.js'
import { declareSlot } from './modules/registry.js'
import './styles/theme.css' import './styles/theme.css'
createRoot(document.getElementById('root')).render( // Publish window.__rg BEFORE rendering and before any module chunk evaluates.
// Installed modules arrive as `<script type="module" src="/modules/<id>/…">`
// tags the server injects into the shell (server/src/utils/htmlShell.js), placed
// after this bundle's own tag; module scripts execute in document order, so they
// resolve their externals against the global this call sets up
// (docs/website/MODULE_API.md §3.2).
publishSharedDependencies()
// Core registered a feature provider here until slice 3, under owner id `core`
// and namespace `uo`, so that the seam was exercised by real content from the
// day it was built. That prediction paid out exactly as written: the extraction
// deleted the registration and the hook it named, and SiteHeader was not touched.
// There is nothing for core to register now — no core nav row carries a
// `feature` — and the filter is a correct no-op until a module supplies one.
// ── Extension slots (MODULE_API.md §3.7) ───────────────────────────────────
//
// Declared HERE, in core's own bundle, which is what makes the ordering a fact
// rather than a hope: module chunks are deferred scripts the shell injects after
// this one (§3.1), so a module can never reach registerExtension before the slot
// it names exists. "Unknown slot" therefore always means a typo or a version
// skew, never a load-order accident — which is why that case throws.
//
// Both slots are named for a PLACE, not for a meaning. `site.footer.status` is
// the spot in the footer's info row, not a declaration that core knows what a
// game server's status is; the label, the target and whether anything renders at
// all belong to whoever fills it. A slot typed by its content would put game
// semantics back into core, which is the thing Phase 3 takes out.
declareSlot('site.footer.status')
// Deliberately the same name as the server's slot (MODULE_API.md §2.4): one
// resource, one extension point, two halves. The module with routes under
// /api/v1/admin/users/:id is the module with something to show on that page.
declareSlot('admin.users.detail')
// The invite-acceptance page's optional next step. Core owns invites — staff are
// invited too — and owned the game-account step inside them until slice 3, which
// meant core reading a `gameAccountSignup` flag and posting to a shard route.
//
// Named for the place, like the other two: it is "the point after an invite has
// been accepted and before the invitee is sent on", not "create a game account".
// Whether there is a step at all is the filling module's decision, made from
// data core does not have; core renders the shell and a skip control, and hands
// over `onDone`. With the slot unfilled the invitee goes straight to the portal,
// which is what core's own code did whenever the flag was off.
declareSlot('player.invite.accepted')
// Core filled the first two itself until slice 3, with the components that were
// inline in SiteFooter.jsx and UserDetail.jsx. Both are gone: the module fills
// all three, and core's own fills had to go for it to be able to — the first
// fill wins, and core registered first (§3.7).
// Render on DOMContentLoaded rather than immediately, and that is the one line
// of core's boot the module system changes.
//
// Deferred scripts — which every `type="module"` script is — execute in document
// order and ALL of them finish before DOMContentLoaded fires. Waiting for that
// event is therefore the guarantee that every installed module has registered
// its routes before React reads the registry: no loading state, no re-render,
// and no ordering race between core's bundle and a module's. A module chunk that
// 404s or throws does not hold the event back, so a broken module costs its own
// pages and not the site.
//
// The readyState check below is `'complete'`, and it is not the obvious
// `'loading'`. A DEFERRED script — which every `type="module"` script is — runs
// after the document has been parsed, so by the time this line executes
// readyState is already `'interactive'`; DOMContentLoaded has NOT fired yet and
// still comes after every deferred script. Testing for `'loading'` therefore
// mounts immediately, before any module chunk has evaluated, and a module's
// routes are missing from the very first render — which looks exactly like a
// module that failed to load: its URL falls through to core's catch-all and
// redirects home. Found by loading a real chunk in a browser; no unit test in
// this repo can see it.
//
// `'complete'` is only reached after `load`, which is strictly later than any
// static deferred script, so this branch is the genuine "the event has already
// been and gone" case and not a wrong guess about our own timing.
function mount() {
createRoot(document.getElementById('root')).render(
<React.StrictMode> <React.StrictMode>
<BrowserRouter> <BrowserRouter>
<App /> <App />
</BrowserRouter> </BrowserRouter>
</React.StrictMode>, </React.StrictMode>,
) )
}
if (document.readyState === 'complete') {
mount()
} else {
document.addEventListener('DOMContentLoaded', mount, { once: true })
}

View File

@@ -0,0 +1,68 @@
// ── <Slot> — where core renders a module's content ─────────────────────────
//
// Phase 3, slice 2 of docs/website/MODULE_SYSTEM.md §2.7.1; the normative
// contract is docs/website/MODULE_API.md §3.7.
//
// The read side of registry.js's extension slots. Core puts one of these where a
// module may contribute to a core page, and gets back either the filling
// component with the props core passed, or nothing at all.
//
// **Nothing at all is the important half.** An instance with no module installed
// renders the identical page it renders today, which is the same untouched-path
// guarantee `withModuleNav` makes for nav — and the reason a core layout can
// place a slot without also acquiring an empty-state to design.
import React from 'react'
import { extensionFor } from './registry.js'
/**
* Contain a module's render failure to the module's own section.
*
* This is where the client differs from the server, deliberately. A module
* *route* that throws costs the module's own page and core does not need to care.
* An extension throws inside CORE's page — the admin's user detail, the site
* footer — and the whole reason core keeps ownership of that page is that it
* stays usable. So a slot renders nothing and logs, rather than taking the
* surrounding page down with it.
*
* A class because that is what React gives us: there is no hook form of
* componentDidCatch, and this is the only error boundary core has.
*/
class SlotBoundary extends React.Component {
constructor(props) {
super(props)
this.state = { failed: false }
}
static getDerivedStateFromError() {
return { failed: true }
}
componentDidCatch(error) {
// Named so the console says whose fault it is: a blank section with an
// anonymous stack is how a module bug becomes core's support ticket.
console.error(`[modules] extension in slot "${this.props.name}" threw and was dropped`, error)
}
render() {
return this.state.failed ? null : this.props.children
}
}
/**
* @param {string} name the slot id, declared by core in main.jsx
* @param {function} [wrap] core markup that only makes sense AROUND a rendered
* extension — a separator, a heading, a rule. Called with the extension's
* element and rendered inside the boundary, so it shares the extension's fate:
* an unfilled slot and a failed one both render nothing at all, decoration
* included. Found in a browser, because the obvious alternative — asking
* whether the slot is filled and rendering the separator alongside — is right
* about the unfilled case and leaves a stray separator behind on the failed one.
* @param {object} props everything else is handed to the filling component
*/
export default function Slot({ name, wrap, ...props }) {
const Extension = extensionFor(name)
if (!Extension) return null
const element = <Extension {...props} />
return <SlotBoundary name={name}>{wrap ? wrap(element) : element}</SlotBoundary>
}

View File

@@ -0,0 +1,56 @@
// Which nav rows a viewer may see, when the answer belongs to a module.
//
// Phase 2, PR 8 of docs/website/MODULE_SYSTEM.md §2.7 (§1.5 states the problem);
// the contract is docs/website/MODULE_API.md §3.3.
//
// Nine of the sixteen rows in the public header used to carry a `feature`, and
// every one of them was a shard surface an admin can disable or gate to a higher
// audience. The provider that answered those questions moved out with the module
// in Phase 3 slice 3, and core cannot call it directly and still be a core. It
// keeps this generic seam instead, and the module fills it.
//
// **The namespace comes from the registration, not from the string.** A row's
// `feature` is resolved by the provider its OWN module registered, so a module
// author writes `feature: 'status'` exactly as it reads today: nothing parses a
// prefix, and a typo'd namespace is not a thing that can exist. Core's own rows
// carry no `moduleId` and resolve against the owner id `core` — which nothing
// registers now that the shard rows are gone, and that is the correct resting
// state rather than a gap: no core nav row carries a `feature`.
//
// Everything here fails OPEN, and that is deliberate: this is presentation, the
// gate is server-side
// (a disabled feature 404s and an out-of-rung one 403s whether or not a link was
// rendered), so an unknown answer shows the link rather than blanking the nav.
// The one thing a UI mistake must never do here is hide a page from someone
// entitled to it.
/**
* The predicate the layouts filter their nav with.
*
* @param {Map<string, {has: (name: string) => boolean} | null | undefined>} flagsByOwner
* one entry per registered provider, keyed by the id of the module that
* registered it. The value is whatever that provider's hook returned this
* render: a Set-like of the flags this viewer may see, or `null` while the
* answer is still in flight.
* @returns {(item: object) => boolean}
*/
export function buildFeatureGate(flagsByOwner) {
return function isVisible(item) {
if (!item || !item.feature) return true
const owner = item.moduleId ?? 'core'
// No provider for this owner: the row names a flag nothing answers for. That
// is the no-module-installed case — no core row carries a `feature` once the
// module is out — and it is a correct no-op rather than a hidden row.
if (!flagsByOwner || !flagsByOwner.has(owner)) return true
const flags = flagsByOwner.get(owner)
// Still loading, or a provider that returned something unusable. Both are
// "we do not know yet", and both show the link.
if (!flags || typeof flags.has !== 'function') return true
return flags.has(item.feature)
}
}
/** The gate an area with no providers gets: everything is visible. */
export const OPEN_GATE = () => true
export default buildFeatureGate

View File

@@ -0,0 +1,65 @@
import { createContext, useContext, useMemo, useState } from 'react'
import { featureProviders } from './registry.js'
import { buildFeatureGate, OPEN_GATE } from './featureGate.js'
// The React half of the feature seam. The decision logic is featureGate.js,
// which is plain JS and therefore testable in a runner with no DOM; this file is
// wiring, the same split registry.js and shared.js already use.
//
// **Calling a hook per provider inside a loop is the point, and it is legal
// here.** The rules of hooks require the same hooks in the same order on every
// render of a component — not a statically known list. The provider list is
// fixed before the first render (registration happens while module chunks
// evaluate, and main.jsx does not mount until DOMContentLoaded), there is no
// unregistering, and the snapshot below freezes it per component instance
// anyway. So the loop's length cannot change between renders of this provider,
// which is the actual requirement.
//
// A provider hook returns a Set-like of the flags this viewer may see, or `null`
// while it is still fetching. Core knows nothing else about it: what a flag
// means, how it is fetched, and what it is gated on are all the module's.
const FeatureGateContext = createContext(OPEN_GATE)
export function ModuleFeaturesProvider({ children }) {
// Snapshotted once. useState's initialiser runs on the first render only, so
// even a provider that somehow registered late cannot change this instance's
// hook count mid-life — it would be ignored until the next mount, which is a
// far better failure than a crashed render.
const [providers] = useState(featureProviders)
// eslint-disable-next-line react-hooks/rules-of-hooks -- fixed-length list, see above
const values = providers.map((provider) => provider.hook())
const gate = useMemo(
() => {
const byOwner = new Map()
// First registration wins for a given owner: a module that registers two
// namespaces answers its own nav rows from the first, rather than from
// whichever happened to be stored last.
providers.forEach((provider, i) => {
if (!byOwner.has(provider.id)) byOwner.set(provider.id, values[i])
})
return buildFeatureGate(byOwner)
},
// One dependency per provider — a fixed-length list, for the same reason the
// hook loop above is fixed-length.
// eslint-disable-next-line react-hooks/exhaustive-deps
[providers, ...values],
)
return <FeatureGateContext.Provider value={gate}>{children}</FeatureGateContext.Provider>
}
/**
* The predicate to filter nav rows with: `(item) => boolean`, true when the row
* carries no `feature` or when its module says this viewer may see it.
*
* Outside a provider it is the open gate, so a component rendered in isolation
* (a test, a preview) shows its whole nav rather than none of it.
*/
export function useFeatureGate() {
return useContext(FeatureGateContext)
}
export default ModuleFeaturesProvider

174
client/src/modules/nav.js Normal file
View File

@@ -0,0 +1,174 @@
// The interleave of module nav items into core's nav.
//
// Phase 2, PR 8 of docs/website/MODULE_SYSTEM.md §2.7 (§1.4 states the problem);
// the normative contract is docs/website/MODULE_API.md §3.3.
//
// **Module items join the BASE array, before anything else happens to it.** That
// is the whole design of this file and the override merge next door forces it:
// `applyNavOverrides` / `buildPublicNav` are keyed by `to` and drop any key the
// base array does not declare (lib/navOverrides.js — deliberately, so a deleted
// route cannot leave a stale row doing something unexpected later). Append
// module items *after* that merge and they are unreachable to Admin →
// Navigation: unorderable, unrelabellable, unhideable. Today's UO rows are all
// three of those things, so appending would make the extraction a visible
// regression for every operator who has ever touched their nav.
//
// So the pipeline gains one step at the front and nothing else changes:
//
// withModuleNav(NAV, area) → admin overrides → role/feature filter → rendered
//
// and the filter stays last, which is what keeps it the boundary an override
// cannot cross (THEMING_AND_NAV.md §7). MODULE_API.md §3.3 wrote those last two
// the other way round; the code is right and the contract was amended.
//
// The result is that a module row is, to everything downstream, an ordinary row.
// Nothing in navOverrides.js, NavEditor.jsx or the layouts knows a module exists.
import { navFor } from './registry.js'
import { isGrouped } from '../lib/navOverrides.js'
// Rows with no group of their own are collected under this key. A Symbol rather
// than a string so it cannot collide with a group an admin or a module names.
const UNGROUPED = Symbol('ungrouped')
/**
* Sort by effective position, where a row that asked for nothing keeps the index
* it already had. Three tie-breaks, in this order: an explicit `order` beats a
* coincidental index (the module said "third", so third), and two explicit
* orders keep registration order, which `navFor` has already put in scan order.
*
* The same rule byOrder/place use in lib/navOverrides.js, and it has to be — an
* admin who then drags that row is editing the position this produced.
*/
function place(entries) {
return entries
.map((entry, index) => ({ ...entry, index }))
.sort((a, b) => a.key - b.key || Number(b.explicit) - Number(a.explicit) || a.index - b.index)
.map(({ item }) => item)
}
function entryFor(item, fallbackKey) {
return { item, key: item.order ?? fallbackKey, explicit: item.order !== undefined }
}
function coreEntries(items) {
return items.map((item, index) => ({ item, key: index, explicit: false }))
}
/** The `to`s a base nav already claims, flat or grouped. */
function claimedPaths(baseNav, grouped) {
return new Set(grouped ? baseNav.flatMap((g) => g.items.map((i) => i.to)) : baseNav.map((i) => i.to))
}
/**
* Drop a module row whose `to` is already on the nav, and say so.
*
* Not a policy about where a module may link — it is that `to` is the KEY the
* override layer stores under and React renders by. Two rows sharing one would
* give an admin a single editor row that silently moves both, and a duplicate
* key in the rendered list. Dropping the newcomer keeps core's row, which is the
* one any existing override was written against.
*
* Fail-safe like every other read in this area: the offending row goes, its
* neighbours stay.
*/
function withoutCollisions(items, claimed) {
const out = []
for (const item of items) {
if (!item || typeof item.to !== 'string' || !item.to) continue
if (claimed.has(item.to)) {
console.warn(
`[modules] nav item "${item.to}" from module "${item.moduleId}" collides with an existing row and was dropped`,
)
continue
}
claimed.add(item.to)
out.push(item)
}
return out
}
// The flat navs — the public header and the player portal.
//
// No groups, so `order` is a position in the one list: core rows are keyed by
// their index and a module row by the `order` it asked for. A module row with no
// order appends after the coded ones, in registration order, rather than jumping
// to the front on a 0 default — the same choice buildPublicNav makes for an
// admin-created link.
function mergeFlat(baseNav, items) {
return place([...coreEntries(baseNav), ...items.map((item, i) => entryFor(item, baseNav.length + i))])
}
// The grouped nav — the admin sidebar.
//
// `group` names an existing core group and the row lands inside it: Moderation
// and System, where today's UO rows already sit (§1.4). An unknown group name
// creates a group at the end rather than dropping the row — a typo must cost a
// position, never a link. A row with no `group` at all lands in a trailing
// untitled group, which renders as ungrouped links; core does not invent a
// display title out of a module id.
//
// An ungrouped row is NOT folded into one of core's own untitled groups
// (Dashboard's, Account's): those are furniture pinned to the top and bottom of
// the sidebar, and a module page does not belong beside "Account".
//
// A group created here is a group as far as everything downstream is concerned,
// including as a destination in Admin → Navigation's "move to section" control:
// `readOverrides` builds its set of legal destinations from the base nav it is
// handed, which is this one.
function mergeGrouped(baseNav, items) {
const titles = new Set(baseNav.map((g) => g.title).filter((t) => typeof t === 'string'))
const into = new Map() // existing group title → rows
const fresh = new Map() // new group title (or UNGROUPED) → rows, first-seen order
for (const item of items) {
const named = typeof item.group === 'string' && item.group ? item.group : null
const key = named ?? UNGROUPED
const bucket = named !== null && titles.has(named) ? into : fresh
if (!bucket.has(key)) bucket.set(key, [])
bucket.get(key).push(item)
}
const kept = baseNav.map((g) => {
const incoming = into.get(g.title)
if (!incoming) return g
return {
...g,
items: place([...coreEntries(g.items), ...incoming.map((item, i) => entryFor(item, g.items.length + i))]),
}
})
const created = [...fresh.entries()].map(([key, rows]) => {
const items_ = place(rows.map((item, i) => entryFor(item, i)))
return key === UNGROUPED ? { items: items_ } : { title: key, items: items_ }
})
return [...kept, ...created]
}
/**
* The base nav a layout should render: core's coded array with every installed
* module's rows for this area interleaved into it.
*
* Returns `baseNav` ITSELF when no module registered anything for this area, so
* an instance with no modules installed renders the identical array it renders
* today — the same "untouched path" guarantee applyNavOverrides makes, and what
* makes a `useMemo` with an empty dependency list around this call honest.
*
* Safe to call once per component and cache: registration completes before the
* first render (main.jsx waits for DOMContentLoaded — MODULE_API.md §3.1) and
* there is no unregistering, so this answer cannot change during a session.
*
* @param {Array} baseNav the coded NAV, flat or grouped
* @param {'public'|'admin'|'player'} area
* @returns {Array} a nav of the same shape
*/
export function withModuleNav(baseNav, area) {
if (!Array.isArray(baseNav)) return []
const grouped = isGrouped(baseNav)
const items = withoutCollisions(navFor(area), claimedPaths(baseNav, grouped))
if (items.length === 0) return baseNav
return grouped ? mergeGrouped(baseNav, items) : mergeFlat(baseNav, items)
}
export default withModuleNav

View File

@@ -0,0 +1,231 @@
// ── The client-side module registry ────────────────────────────────────────
//
// Phase 2, PR 7 of docs/website/MODULE_SYSTEM.md §2.7. The normative contract is
// docs/website/MODULE_API.md §3.3; where the two disagree, the contract wins.
//
// A module's prebuilt chunk registers its routes, its nav entries and its feature
// provider here, and core reads them back. This is the client twin of the
// server's modules/loader.js — with one structural difference worth stating,
// because it is what makes the file this short: core *hands* the registry to the
// module (on `window.__rg`, see shared.js) rather than discovering it. There is
// nothing to scan, nothing to validate a manifest against, and no failure mode
// where half a module is registered.
//
// **Timing is the whole design.** Module chunks are `<script type="module" src>`
// tags the server injects before `</body>` (server/src/utils/htmlShell.js), after
// core's own bundle. Module scripts are deferred, so they evaluate after that
// bundle has run — which is where `window.__rg` is published — and all of them
// finish before DOMContentLoaded. main.jsx waits for that same event before
// calling render(), so registration is complete before React reads any of this.
//
// That is what buys the simplicity here: registration is a plain synchronous
// write with no subscribers, not an observable store, because nothing can
// register after the first render. If that ever stops being true it changes in
// this file and in main.jsx, not in a dozen consumers.
//
// What PR 7 wires up is `routesFor` (App.jsx). `navFor` and `featureProviderFor`
// are stored and returned faithfully but core does not read them yet — PR 8 adds
// the nav interleave and the feature-provider seam. Storing them is not the kind
// of accepting stub the server's registries refused to be: nothing is discarded
// here, so a module that registers nav in this core gets it back from `navFor`.
const routes = { public: [], admin: [], player: [] }
const nav = { public: [], admin: [], player: [] }
const providers = new Map()
// slot name → { Component, filledBy }.
const slots = new Map()
const registered = new Set()
const AREAS = ['public', 'admin', 'player']
function assertArea(area, call) {
if (!AREAS.includes(area)) throw new Error(`${call}: unknown area "${area}"`)
}
/**
* Route components, by area.
*
* @param {string} id the module id — the URL segment its routes are namespaced under
* @param {{public?: Array, admin?: Array, player?: Array}} byArea
* each entry `{ path, element, gate? }`. `path` is relative to the module's
* namespace; core prefixes it and mounts it inside the area's existing wrapper
* (`/<id>/…` under MaintenanceGate, `/admin/<id>/…` under RequireAuth +
* AdminLayout, `/player/<id>/…` under RequirePlayer + PlayerPortalLayout).
* `gate` is an optional `{ roles: [...] }` that core applies as its own
* RoleGate — a module cannot supply an auth wrapper, because the sidebar and
* the route table have to agree about who may see what (§3.3).
*/
export function registerRoutes(id, byArea) {
for (const [area, list] of Object.entries(byArea || {})) {
assertArea(area, 'registerRoutes')
for (const route of list || []) {
// Prefixed HERE rather than by the module: a module cannot claim a path
// outside its own namespace however it spells `path` — a leading `/`, a
// trailing one, or several — because it never gets to write the segment
// its routes hang under.
const path = `${id}/${String(route.path || '').replace(/^\/+/, '')}`.replace(/\/+$/, '')
routes[area].push({ ...route, path, moduleId: id })
}
}
registered.add(id)
}
/**
* Nav entries, interleaved into CORE groups rather than appended as a block.
*
* Today's UO items sit inside core's own Moderation and System groups; a "UO"
* group at the bottom of the sidebar would be a visible regression on the day
* the module is extracted (MODULE_SYSTEM.md §1.4). `group` names an existing
* core group, `order` sorts within it, and an unknown group name appends rather
* than dropping the item — a mis-typed group must cost a position, never a link.
*
* `icon` is a component core renders exactly as it renders its own rows' icons
* (1.3.0). It exists because without it the six UO rows would have extracted as
* the only text-only entries in a sidebar where every other row has a glyph,
* which reads as breakage rather than as a design. Core does not supply a
* fallback: a module that omits it gets no icon, the same as a core row that
* omits it, and inventing one would be core making a presentation choice for
* content it knows nothing about. Note that `icon` is already among the fields
* an override may not touch (lib/navOverrides.js) — the concept predates a
* module being able to supply one.
*
* @param {string} id
* @param {{area: string, items: Array<{label, to, group?, order?, roles?, feature?, icon?}>}} spec
*/
export function registerNav(id, spec) {
const { area, items } = spec || {}
assertArea(area, 'registerNav')
for (const item of items || []) nav[area].push({ ...item, moduleId: id })
registered.add(id)
}
/**
* The hook that answers "which of this module's features may this viewer see".
*
* Core keeps a generic flag context and owns none of the semantics
* (MODULE_SYSTEM.md §1.5). With no module installed the nav filter is a correct
* no-op, because no core nav item carries a `feature` — which has been literally
* true since Phase 3 slice 3 took the nine shard-gated rows out.
*/
export function registerFeatureProvider(id, namespace, hook) {
providers.set(namespace, { id, hook })
registered.add(id)
}
// ── Extension slots (§3.7) ─────────────────────────────────────────────────
//
// The client twin of the server's declareSlot/registerExtension, and the same
// rule in both halves: core declares a slot, ONLY core declares one, and at most
// one module fills it. Core renders `<Slot name>` (Slot.jsx) and gets nothing
// back when the slot is unfilled — so an instance with no module installed
// renders exactly what it renders today.
//
// A slot is named for a PLACE, never for a meaning. `site.footer.status` is a
// position in the footer and the styling that goes with it; the label, the
// target, the data and whether anything renders at all are the module's. The
// moment core types a slot by its content it has re-acquired the game semantics
// this whole extraction removes.
/**
* @param {string} name the slot id. Core-only — deliberately not on the
* `registry` object handed to modules.
*/
export function declareSlot(name) {
if (slots.has(name)) throw new Error(`extension slot "${name}" already declared`)
slots.set(name, { Component: null, filledBy: null })
}
/**
* Fill a declared slot with a component.
*
* **This is the one place the client registry is not fail-open**, and the
* asymmetry is deliberate. A dropped nav row costs a link the viewer can reach
* another way; a silently dropped extension is invisible to everyone including
* its author. So an unknown slot, a non-component, and a second fill all throw —
* exactly as the server's checkExtensionShape does.
*
* A throw here is always a programming error and never a race, because
* declaration structurally precedes filling: core declares in main.jsx, inside
* its own bundle, and every module chunk is a deferred script injected after it
* (§3.1).
*/
export function registerExtension(id, slot, Component) {
const entry = slots.get(slot)
if (!entry) throw new Error(`registerExtension: unknown extension slot "${slot}"`)
if (typeof Component !== 'function') throw new Error(`registerExtension: ${slot} is not a component`)
if (entry.filledBy) throw new Error(`extension slot "${slot}" is already filled by "${entry.filledBy}"`)
entry.Component = Component
entry.filledBy = id
registered.add(id)
}
/**
* The filling component, or null.
*
* Read by Slot.jsx and nothing else — deliberately. There is no `hasExtension`
* for a core layout to branch on, because a layout that asks whether a slot is
* filled and then renders its own decoration alongside gets the *failed* case
* wrong: the extension is filled, so the decoration renders, and the component
* then throws into the boundary leaving the decoration behind on its own. Core
* decorates through `<Slot wrap>` instead, which puts the decoration inside the
* boundary where it shares the extension's fate. (Found in a browser, with the
* footer's separator.)
*
* Undeclared and unfilled both read null: reading is fail-safe, and only writing
* is strict.
*/
export const extensionFor = (slot) => (slots.get(slot) || {}).Component || null
export const routesFor = (area) => routes[area] || []
// Sorted by the `order` a module asked for. Array#sort is stable in every engine
// this ships to, so two modules asking for the same slot keep load order —
// which is alphabetical by id, the same order the server scans in (§4.2).
export const navFor = (area) =>
[...(nav[area] || [])].sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
export const featureProviderFor = (namespace) => providers.get(namespace)
/**
* Every registered provider, for core's feature context to call.
*
* Exported from the module but deliberately NOT a member of the `registry`
* object below: a module asks for a namespace it knows the name of, and has no
* business enumerating what everyone else registered. Core needs the list
* because it has to call each hook — unconditionally, in a fixed order, at the
* top of a component (modules/features.jsx).
*/
export const featureProviders = () =>
[...providers.entries()].map(([namespace, { id, hook }]) => ({ id, namespace, hook }))
export const registeredIds = () => [...registered]
/** Test seam. Nothing in the app calls this — there is no unregistering. */
export function _reset() {
for (const area of AREAS) {
routes[area].length = 0
nav[area].length = 0
}
providers.clear()
// Declarations go too, unlike the server's, where a slot is declared once at
// require time by the router that owns it. Core declares its slots in
// main.jsx — the one file no test loads — so on this side there is nothing
// declared at import time for a surviving declaration to protect.
slots.clear()
registered.clear()
}
// The object handed to modules on window.__rg.registry. Deliberately the write
// calls plus the read ones: a module reading `routesFor` is how it finds out
// another module is installed, which is the only supported form of module-to-
// module awareness (there is no dependency resolution).
export const registry = {
registerRoutes,
registerNav,
registerFeatureProvider,
registerExtension,
routesFor,
navFor,
featureProviderFor,
registeredIds,
}

View File

@@ -0,0 +1,101 @@
// ── window.__rg — the shared-dependency global ─────────────────────────────
//
// Phase 2, PR 7 of docs/website/MODULE_SYSTEM.md §2.7; the normative shape is
// docs/website/MODULE_API.md §3.2.
//
// A module's client half is a PREBUILT ESM chunk — the operator never builds
// anything (MODULE_SYSTEM.md §1.14) — served same-origin and loaded under
// `script-src 'self'` with no 'unsafe-inline'. That combination is what rules out
// an import map: an import map has to be an inline `<script type="importmap">`,
// and the policy forbids inline scripts outright. So the shared dependencies ride
// on a global, and the module's Rollup externals are aliased to two-line shims
// that re-export from it (§3.6).
//
// **There is exactly one React in the page and core owns it.** A module that
// bundled its own would get a second hook dispatcher and fail at its first
// useState. That is the same rule the server half enforces for `express` and
// `express-validator` on `ctx`, and for the same reason: anything shared between
// core and a module is owned by core and HANDED OVER, never resolved by the
// module.
import * as react from 'react'
import * as reactDom from 'react-dom/client'
import * as router from 'react-router-dom'
// The automatic JSX runtime, and it is not decoration. A module's bundler
// compiles every .jsx file to imports from `react/jsx-runtime` under the modern
// default, and those have to resolve to CORE's React like every other import.
// Without it here a module would have to build with `jsxRuntime: 'classic'`;
// with it, a module uses the default its tooling already assumes.
import * as jsxRuntime from 'react/jsx-runtime'
import { registry } from './registry.js'
import { MODULE_API_VERSION } from './version.js'
import PublicLayout from '../components/PublicLayout.jsx'
import PageHeader from '../components/PageHeader.jsx'
import { Loading, ErrorState, EmptyState } from '../components/PageState.jsx'
import { useAsync } from '../lib/useAsync.js'
import { useAuth } from '../contexts/AuthContext.jsx'
import { useSite } from '../contexts/SiteContext.jsx'
import { request, ApiError, BASE } from '../api/client.js'
// The UI kit is CURATED AND CLOSED (§3.4), not a re-export of components/. These
// seven are what the smallest UO page already needs beyond React and the router:
// without them a module either reaches into core's tree — violating the
// zero-import rule the whole boundary rests on — or ships its own copies, which
// means a module page that does not look like the site it is installed in, and
// that drifts further every time core's layout changes.
//
// Adding a member is a MINOR MODULE_API_VERSION bump; changing a member's props
// is a MAJOR one. That is a real constraint on core's own refactoring and it is
// the price of the boundary being worth anything.
//
// `AdminPage` appears in §3.4's table and is deliberately absent: core has no
// such component — admin views are plain markup inside AdminLayout — and
// inventing one to satisfy a table would be a core change with no consumer until
// Phase 3. The contract is amended rather than the code padded, and adding it
// later costs a minor bump, which is exactly the case the versioning is for.
const ui = {
PublicLayout,
PageHeader,
Loading,
ErrorState,
EmptyState,
useAsync,
useAuth,
useSite,
}
// The request PRIMITIVE, not the `api` object (§3.5): a module builds its own
// namespace over `request` and owns the paths it calls, which is right, because
// it owns the routes at the other end.
//
// `BASE` was in §3.5 from the start and missing from this object until slice 3,
// which is when something first needed it. `request` is fetch-only, so an
// EventSource — the shard's live feed is two of them — has to build its own URL,
// and the alternative is a module hardcoding `/api/v1`: an assertion about where
// core mounts its API that core has never promised to keep.
const api = { request, ApiError, BASE }
/**
* Publish `window.__rg`. Called by main.jsx before it renders, and before any
* module chunk evaluates.
*
* Frozen, one level down as well as at the top: the object a module reaches for
* its React is not somewhere a module gets to leave something for the next one.
* Cross-module communication is a thing the contract does not have, and an
* unfrozen global is how a codebase acquires one by accident.
*/
export function publishSharedDependencies() {
window.__rg = Object.freeze({
version: MODULE_API_VERSION,
react,
reactDom,
router,
jsxRuntime,
registry,
ui: Object.freeze(ui),
api: Object.freeze(api),
})
return window.__rg
}

View File

@@ -0,0 +1,29 @@
// The client's copy of MODULE_API_VERSION. It must equal the server's
// (server/src/modules/version.js) — the two halves version ONE contract
// (docs/website/MODULE_API.md §1.1), and a module checks whichever half it is
// talking to: `coreApi` against the server's at load time, `window.__rg.version`
// against the client's before it registers anything.
//
// Duplicated rather than fetched, and that is deliberate. The value has to be on
// `window.__rg` before the first module chunk evaluates, which is earlier than
// any network round trip could answer — a fetched version would mean either an
// await before render or a module reading `undefined`. The cost of the copy is
// that the two files can drift, so a test asserts they agree
// (client/test/moduleRegistry.test.js) rather than trusting a bump to remember
// both.
// 1.3.0 — three additions, all from Phase 3 slice 3 needing them: a nav item may
// carry an `icon` component (§3.3), core declares a third slot
// `player.invite.accepted` (§3.7), and `window.__rg.api` gained `BASE`, which
// §3.5 always documented and shared.js never published. Additive throughout: a
// module written against 1.2.0 is unaffected. The server half is untouched and
// bumps anyway, for the reason below.
// 1.2.0 — `registry` gained `registerExtension` and core gained extension slots
// (MODULE_API.md §3.7). The first change to window.__rg since 1.0.0, and an
// addition: a module that never fills a slot is unaffected. The server half is
// untouched and bumps anyway, for the reason below.
// 1.1.0 — the server's ctx gained activity.log, users.getById, site.baseUrl and
// the rate-limit factory (MODULE_API.md §2.3). Nothing on window.__rg changed,
// but the two halves state ONE version: a module declares a single coreApi range
// and is served one chunk, so a client that claimed 1.0.0 while the server
// answered 1.1.0 would be two answers to one question.
export const MODULE_API_VERSION = '1.3.0'

View File

@@ -1,8 +1,14 @@
import { useEffect, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom' import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'
import MoonDot from '../../components/MoonDot.jsx' import MoonDot from '../../components/MoonDot.jsx'
import BrandLogo from '../../components/BrandLogo.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx' import { useAuth } from '../../contexts/AuthContext.jsx'
import { useSite } from '../../contexts/SiteContext.jsx' import { useSite } from '../../contexts/SiteContext.jsx'
import { applyNavOverrides } from '../../lib/navOverrides.js'
import { useNavOverrides } from '../../lib/useNavOverrides.js'
import { withModuleNav } from '../../modules/nav.js'
import { useFeatureGate } from '../../modules/features.jsx'
import { navItemVisibleTo, allowedPathsFor, isAllowedPath } from '../../lib/adminNav.js'
// Small inline stroke icons (16px, currentColor) — same style as ProviderIcon. // Small inline stroke icons (16px, currentColor) — same style as ProviderIcon.
// One shared frame keeps them terse; each item just supplies its path(s). // One shared frame keeps them terse; each item just supplies its path(s).
@@ -37,14 +43,19 @@ const IconKey = () => <Icon><circle cx="8" cy="12" r="4" /><path d="M12 12h9M18
const IconBot = () => <Icon><rect x="4" y="8" width="16" height="11" rx="2" /><path d="M12 8V4M8 13h.01M16 13h.01M9 17h6" /></Icon> const IconBot = () => <Icon><rect x="4" y="8" width="16" height="11" rx="2" /><path d="M12 8V4M8 13h.01M16 13h.01M9 17h6" /></Icon>
const IconPulse = () => <Icon><path d="M3 12h3l2 6 4-14 2 8h7" /></Icon> const IconPulse = () => <Icon><path d="M3 12h3l2 6 4-14 2 8h7" /></Icon>
const IconUser = () => <Icon><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></Icon> const IconUser = () => <Icon><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></Icon>
const IconShard = () => <Icon><path d="M12 2l7 6-7 14-7-14z" /><path d="M5 8h14" /></Icon> const IconNav = () => <Icon><path d="M4 6h16M4 12h16M4 18h10" /><circle cx="18" cy="18" r="2.5" /></Icon>
const IconPalette = () => <Icon><path d="M12 3a9 9 0 1 0 0 18 2 2 0 0 0 1.6-3.2 2 2 0 0 1 1.6-3.2H18a3 3 0 0 0 3-3 9 9 0 0 0-9-8.6z" /><circle cx="7.5" cy="11.5" r="1" /><circle cx="10.5" cy="7.5" r="1" /><circle cx="15" cy="8.5" r="1" /></Icon>
// Nav is grouped into collapsible categories. A group with no `title` renders // Nav is grouped into collapsible categories. A group with no `title` renders
// its items ungrouped (Dashboard at top, Account at bottom). Each item's `roles` // its items ungrouped (Dashboard at top, Account at bottom). Each item's `roles`
// (when present) matches server-side enforcement so the sidebar never shows a // (when present) matches server-side enforcement so the sidebar never shows a
// link that would 403; an item without `roles` is visible to everyone. // link that would 403; an item without `roles` is visible to everyone.
// Moderators are further confined to just their section + account (see below). // Moderators are further confined to just their section + account (see below).
const NAV = [ //
// Exported because Admin -> Navigation edits this list. It stays declared here:
// the editor may relabel, reorder, hide and regroup, and `roles` is never its to
// touch (§7) — navItemVisibleTo below is the filter that still decides.
export const NAV = [
{ {
items: [ items: [
{ to: '/admin', label: 'Dashboard', end: true, icon: IconHome, roles: ['admin', 'editor', 'moderator'] }, { to: '/admin', label: 'Dashboard', end: true, icon: IconHome, roles: ['admin', 'editor', 'moderator'] },
@@ -64,8 +75,6 @@ const NAV = [
items: [ items: [
{ to: '/admin/moderation', label: 'Moderation', icon: IconShield, roles: ['admin', 'moderator'] }, { to: '/admin/moderation', label: 'Moderation', icon: IconShield, roles: ['admin', 'moderator'] },
{ to: '/admin/moderation/appeals', label: 'Appeals', icon: IconShield, roles: ['admin', 'moderator'] }, { to: '/admin/moderation/appeals', label: 'Appeals', icon: IconShield, roles: ['admin', 'moderator'] },
{ to: '/admin/shard-ops', label: 'In-Game Ops', icon: IconShard, roles: ['admin', 'moderator'] },
{ to: '/admin/houses', label: 'Houses', icon: IconShard, roles: ['admin', 'moderator'] },
], ],
}, },
{ {
@@ -74,16 +83,16 @@ const NAV = [
{ to: '/admin/users', label: 'Users', icon: IconUsers, roles: ['admin'] }, { to: '/admin/users', label: 'Users', icon: IconUsers, roles: ['admin'] },
{ to: '/admin/invites', label: 'Invites', icon: IconUsers, roles: ['admin'] }, { to: '/admin/invites', label: 'Invites', icon: IconUsers, roles: ['admin'] },
{ to: '/admin/settings', label: 'Settings', icon: IconGear, roles: ['admin'] }, { to: '/admin/settings', label: 'Settings', icon: IconGear, roles: ['admin'] },
{ to: '/admin/appearance', label: 'Appearance', icon: IconPalette, roles: ['admin'] },
{ to: '/admin/navigation', label: 'Navigation', icon: IconNav, roles: ['admin'] },
{ to: '/admin/hero', label: 'Hero Editor', icon: IconHero, roles: ['admin'] }, { to: '/admin/hero', label: 'Hero Editor', icon: IconHero, roles: ['admin'] },
{ to: '/admin/auth-providers', label: 'Authentication', icon: IconKey, roles: ['admin'] }, { to: '/admin/auth-providers', label: 'Authentication', icon: IconKey, roles: ['admin'] },
{ to: '/admin/discord-bot', label: 'Discord Bot', icon: IconBot, roles: ['admin'] }, { to: '/admin/discord-bot', label: 'Discord Bot', icon: IconBot, roles: ['admin'] },
{ to: '/admin/shard', label: 'Shard (uo-link)', icon: IconShard, roles: ['admin'] },
{ to: '/admin/bot-activity', label: 'Web Bot Activity', icon: IconPulse, roles: ['admin'] }, { to: '/admin/bot-activity', label: 'Web Bot Activity', icon: IconPulse, roles: ['admin'] },
], ],
}, },
{ {
items: [ items: [
{ to: '/admin/characters', label: 'My Characters', icon: IconShard },
{ to: '/admin/account', label: 'Account', icon: IconUser }, { to: '/admin/account', label: 'Account', icon: IconUser },
], ],
}, },
@@ -91,6 +100,27 @@ const NAV = [
const COLLAPSE_KEY = 'admin.nav.collapsed' const COLLAPSE_KEY = 'admin.nav.collapsed'
// The one row an override may never hide: the nav editor itself, which is the
// only screen that can un-hide anything. The write path already refuses it
// (server/src/utils/navOverrides.js) and the editor's own toggle is disabled —
// this is the third guard, and the one that also covers a row edited straight
// in the database. Cheap, and it makes "cannot be hidden" true without
// qualification.
const UNHIDEABLE = '/admin/navigation'
function keepEditorReachable(overrides) {
const entry = overrides?.[UNHIDEABLE]
if (!entry || entry.hidden !== true) return overrides
const { hidden, ...rest } = entry
return { ...overrides, [UNHIDEABLE]: rest }
}
// Who may see a sidebar row, and where that lets them go, both derived from the
// row's own `roles` — lib/adminNav.js, which is where the two hardcoded path
// lists this component used to carry went (MODULE_SYSTEM.md §1.4). Re-exported
// because Admin -> Navigation has always imported it from here.
export { navItemVisibleTo }
const TITLES = { const TITLES = {
'/admin': 'Dashboard', '/admin': 'Dashboard',
'/admin/posts': 'Posts', '/admin/posts': 'Posts',
@@ -99,24 +129,34 @@ const TITLES = {
'/admin/hero': 'Hero Editor', '/admin/hero': 'Hero Editor',
'/admin/moderation': 'Moderation', '/admin/moderation': 'Moderation',
'/admin/moderation/appeals': 'Appeals', '/admin/moderation/appeals': 'Appeals',
'/admin/shard-ops': 'In-Game Ops',
'/admin/houses': 'House Registry',
'/admin/settings': 'Site Settings', '/admin/settings': 'Site Settings',
'/admin/appearance': 'Appearance',
'/admin/navigation': 'Navigation',
'/admin/activity': 'Activity Log', '/admin/activity': 'Activity Log',
'/admin/bot-activity': 'Web Bot Activity', '/admin/bot-activity': 'Web Bot Activity',
'/admin/discord-bot': 'Discord Bot', '/admin/discord-bot': 'Discord Bot',
'/admin/shard': 'Shard (uo-link)',
'/admin/characters': 'My Characters',
'/admin/auth-providers': 'Authentication', '/admin/auth-providers': 'Authentication',
'/admin/users': 'Users', '/admin/users': 'Users',
'/admin/invites': 'Invites', '/admin/invites': 'Invites',
'/admin/account': 'Account Security', '/admin/account': 'Account Security',
} }
// An installed module's admin pages are not in TITLES and cannot be — core does
// not know what they are called. Their nav row does, so the row is the title:
// the longest matching module row wins, so a detail page under a section titles
// as that section rather than falling through to a bare "Admin". Restricted to
// rows a module registered, which is what keeps every core path resolving
// through TITLES and sectionTitle exactly as it does today.
function moduleTitle(baseNav, pathname) {
return baseNav
.flatMap((g) => g.items)
.filter((i) => i.moduleId && (pathname === i.to || pathname.startsWith(`${i.to}/`)))
.sort((a, b) => b.to.length - a.to.length)[0]?.label
}
// Fallback page title for dynamic sub-routes not in the exact-match TITLES map. // Fallback page title for dynamic sub-routes not in the exact-match TITLES map.
function sectionTitle(pathname) { function sectionTitle(pathname) {
if (pathname.startsWith('/admin/moderation')) return 'Moderation' if (pathname.startsWith('/admin/moderation')) return 'Moderation'
if (pathname.startsWith('/admin/characters')) return 'My Characters'
if (pathname.startsWith('/admin/users/')) return 'User' if (pathname.startsWith('/admin/users/')) return 'User'
return 'Admin' return 'Admin'
} }
@@ -137,27 +177,43 @@ const navBtnBase = {
export default function AdminLayout() { export default function AdminLayout() {
const { user, logout } = useAuth() const { user, logout } = useAuth()
const { mode, siteTitle } = useSite() const { mode, siteTitle } = useSite()
const navOverrides = useNavOverrides()
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const title = TITLES[location.pathname] || sectionTitle(location.pathname) const isVisible = useFeatureGate()
// Core's rows plus every installed module's, before the override merge sees
// them — so a module row is editable in Admin -> Navigation like any other
// (modules/nav.js). Computed once: the registry is fixed before the first
// render and nothing unregisters.
const baseNav = useMemo(() => withModuleNav(NAV, 'admin'), [])
const title =
TITLES[location.pathname] || moduleTitle(baseNav, location.pathname) || sectionTitle(location.pathname)
// The hero canvas editor needs room — let it use the full content width. // The hero canvas editor needs room — let it use the full content width.
const wide = location.pathname === '/admin/hero' const wide = location.pathname === '/admin/hero'
const modeDot = mode === 'live' ? 'var(--mode-live)' : 'var(--mode-maint)' const modeDot = mode === 'live' ? 'var(--mode-live)' : 'var(--mode-maint)'
// Moderators only get the moderation section (Discord + in-game ops) + their
// own account security.
const isModerator = user?.role === 'moderator' const isModerator = user?.role === 'moderator'
const MOD_PATHS = ['/admin/moderation', '/admin/moderation/appeals', '/admin/shard-ops', '/admin/houses', '/admin/account']
const visible = (item) => { // An admin may relabel, reorder, hide and regroup these rows from Admin →
if (item.roles && !item.roles.includes(user?.role)) return false // Navigation. The merge runs FIRST and the role filter after it, so the filter
if (isModerator) return MOD_PATHS.includes(item.to) // stays the boundary: an override cannot show a moderator a row their role
return true // gate hides, whatever it says. With no stored row applyNavOverrides returns
} // NAV itself and this is exactly the code that ran before the feature.
// Drop items the current role can't see, then drop any now-empty group so an const navGroups = useMemo(
// empty category header never renders. () =>
const navGroups = NAV applyNavOverrides(baseNav, keepEditorReachable(navOverrides.nav_admin))
.map((g) => ({ ...g, items: g.items.filter(visible) })) .map((g) => ({
.filter((g) => g.items.length > 0) ...g,
// `isVisible` is a no-op for every core row — none carries a `feature`
// — and is applied here so that a module row which does carry one is
// gated on the sidebar rather than silently advertised.
items: g.items.filter((item) => navItemVisibleTo(item, user?.role) && isVisible(item)),
}))
// Drop any now-empty group so an empty category header never renders.
.filter((g) => g.items.length > 0),
[baseNav, navOverrides.nav_admin, user?.role, isVisible],
)
// Accordion: track which titled categories are collapsed. Persist across // Accordion: track which titled categories are collapsed. Persist across
// reloads; default all-open. The group holding the active route auto-opens. // reloads; default all-open. The group holding the active route auto-opens.
@@ -183,17 +239,22 @@ export default function AdminLayout() {
g.title && g.items.some((i) => (i.end ? location.pathname === i.to : location.pathname.startsWith(i.to))) g.title && g.items.some((i) => (i.end ? location.pathname === i.to : location.pathname.startsWith(i.to)))
)?.title )?.title
// Where a moderator may go, from the same `roles` that decide what they see.
// It used to be a third hardcoded list — a prefix check over three paths —
// which disagreed with the sidebar's own five-path allowlist: `/admin/houses`
// was on the sidebar and not in the redirect, so a moderator who clicked
// Houses in their own nav was bounced straight back to Moderation. One
// derivation cannot disagree with itself, which is the point of deriving it.
const allowed = useMemo(() => allowedPathsFor(baseNav, user?.role), [baseNav, user?.role])
// Confine a moderator who deep-links (or is redirected to the index) to a page // Confine a moderator who deep-links (or is redirected to the index) to a page
// outside their remit — the API would 403 anyway, so send them to their home. // outside their remit — the API would 403 anyway, so send them to their home.
useEffect(() => { useEffect(() => {
if (!isModerator) return if (!isModerator) return
const p = location.pathname if (!isAllowedPath(location.pathname, allowed)) {
const allowed =
p.startsWith('/admin/moderation') || p.startsWith('/admin/shard-ops') || p === '/admin/account'
if (!allowed) {
navigate('/admin/moderation', { replace: true }) navigate('/admin/moderation', { replace: true })
} }
}, [isModerator, location.pathname, navigate]) }, [isModerator, location.pathname, navigate, allowed])
// Keep the admin out of search indexes (belt-and-suspenders with robots.txt). // Keep the admin out of search indexes (belt-and-suspenders with robots.txt).
useEffect(() => { useEffect(() => {
@@ -224,6 +285,7 @@ export default function AdminLayout() {
}} }}
> >
<div style={{ padding: '22px 22px 18px', borderBottom: '1px solid var(--line-soft)', display: 'flex', alignItems: 'center', gap: 10 }}> <div style={{ padding: '22px 22px 18px', borderBottom: '1px solid var(--line-soft)', display: 'flex', alignItems: 'center', gap: 10 }}>
<BrandLogo height={24} />
<MoonDot /> <MoonDot />
<div> <div>
<div className="display" style={{ fontSize: '1.02rem', color: 'var(--head)', letterSpacing: '0.03em' }}> <div className="display" style={{ fontSize: '1.02rem', color: 'var(--head)', letterSpacing: '0.03em' }}>

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Link, useNavigate, useLocation } from 'react-router-dom' import { Link, useNavigate, useLocation } from 'react-router-dom'
import MoonDot from '../../components/MoonDot.jsx' import MoonDot from '../../components/MoonDot.jsx'
import BrandLogo from '../../components/BrandLogo.jsx'
import ProviderIcon from '../../components/ProviderIcon.jsx' import ProviderIcon from '../../components/ProviderIcon.jsx'
import TrustLimitModal from '../../components/security/TrustLimitModal.jsx' import TrustLimitModal from '../../components/security/TrustLimitModal.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx' import { useAuth } from '../../contexts/AuthContext.jsx'
@@ -123,8 +124,16 @@ export default function AdminLogin() {
setBusy(true) setBusy(true)
try { try {
if (ssoTotp) { if (ssoTotp) {
const { returnTo } = await ssoLoginTotp(code) // Trust works on the SSO second factor exactly as it does on the password
navigate(returnTo || '/admin', { replace: true }) // one — the IdP already proved the first factor.
const data = await ssoLoginTotp(code.trim(), { trustDevice })
const to = data.returnTo || '/admin'
if (data.trustLimitReached) {
setTrustLimit({ devices: data.devices || [], dest: to })
setBusy(false)
return
}
navigate(to, { replace: true })
} else { } else {
const entered = code.trim() const entered = code.trim()
const data = await loginTotp(challenge, useRecovery ? '' : entered, { const data = await loginTotp(challenge, useRecovery ? '' : entered, {
@@ -173,6 +182,10 @@ export default function AdminLogin() {
<div style={{ width: '100%', maxWidth: 400 }}> <div style={{ width: '100%', maxWidth: 400 }}>
<div style={{ textAlign: 'center', marginBottom: 26 }}> <div style={{ textAlign: 'center', marginBottom: 26 }}>
<div style={{ marginBottom: 14 }}> <div style={{ marginBottom: 14 }}>
{/* Stacked above the moon rather than beside it: this layout is
centered text, and a flex row here would change the block's
height on instances with no logo. */}
<BrandLogo height={34} style={{ margin: '0 auto 12px' }} />
<MoonDot size={15} glow={0.55} /> <MoonDot size={15} glow={0.55} />
</div> </div>
<h1 className="display" style={{ margin: 0, fontSize: '1.7rem', letterSpacing: '0.04em', color: 'var(--head)' }}> <h1 className="display" style={{ margin: 0, fontSize: '1.7rem', letterSpacing: '0.04em', color: 'var(--head)' }}>
@@ -251,12 +264,14 @@ export default function AdminLogin() {
{useRecovery ? 'Enter one of your saved single-use recovery codes.' : 'Enter the code from your authenticator app.'} {useRecovery ? 'Enter one of your saved single-use recovery codes.' : 'Enter the code from your authenticator app.'}
</span> </span>
</label> </label>
{!ssoTotp && ( {/* Offered on the SSO second factor too — the trust is on the device,
not on how the first factor was proved. */}
<label className="sans" style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, color: 'var(--muted)', fontSize: '0.84rem' }}> <label className="sans" style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, color: 'var(--muted)', fontSize: '0.84rem' }}>
<input type="checkbox" checked={trustDevice} onChange={(e) => setTrustDevice(e.target.checked)} /> <input type="checkbox" checked={trustDevice} onChange={(e) => setTrustDevice(e.target.checked)} />
Trust this device for 30 days (skip the code next time) Trust this device for 30 days (skip the code next time)
</label> </label>
)} {/* Recovery codes remain password-login only: the SSO second step
verifies an authenticator code against the staged challenge. */}
{!ssoTotp && ( {!ssoTotp && (
<button <button
type="button" type="button"

View File

@@ -1,29 +0,0 @@
import { useParams, Link } from 'react-router-dom'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import CharacterSheet from '../../../components/CharacterSheet.jsx'
import { useAsync } from '../../../lib/useAsync.js'
import { api } from '../../../api/client.js'
// A staff member's own character sheet inside the admin shell. Owner-checked —
// the endpoint only returns a sheet for a character on the caller's linked account.
export default function AdminCharacter() {
const { serial } = useParams()
const { loading, error, data } = useAsync(() => api.admin.shard.char(serial), [serial])
const restarting = error && error.status === 503
const forbidden = error && error.status === 403
return (
<div style={{ maxWidth: 760 }}>
<p style={{ margin: '0 0 18px' }}>
<Link to="/admin/characters" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.86rem' }}>
Back to my characters
</Link>
</p>
{loading && <Loading />}
{restarting && <ErrorState message="The game server is restarting — try again shortly." />}
{forbidden && <ErrorState message="That character is not on an account linked to you." />}
{error && !restarting && !forbidden && <ErrorState message="Could not load that character right now." />}
{!loading && !error && data && <CharacterSheet char={data} moderation />}
</div>
)
}

View File

@@ -1,18 +0,0 @@
import CharacterStats from '../../../components/CharacterStats.jsx'
import GameAccounts from '../../../components/GameAccounts.jsx'
import VendorSales from '../../../components/VendorSales.jsx'
import { api } from '../../../api/client.js'
// Staff link their OWN in-game account and view their characters — the same
// shared component players use, pointed at the staff self-service endpoints.
// Sits inside the Admin shell, which supplies the "My Characters" page header;
// stat tiles bring it to parity with the Player Portal's Characters page.
export default function AdminCharacters() {
return (
<section style={{ maxWidth: 760 }}>
<CharacterStats scope={api.admin.shard} />
<GameAccounts scope={api.admin.shard} charTo={(serial) => `/admin/characters/${serial}`} />
<VendorSales fetchSales={api.admin.shard.sales} />
</section>
)
}

View File

@@ -0,0 +1,358 @@
import { useEffect, useMemo, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
import { useSite } from '../../../contexts/SiteContext.jsx'
import { parseJsonSetting } from '../../../lib/settingsJson.js'
import BrandAssetsPanel from './BrandAssetsPanel.jsx'
// Admin · Appearance — the theme and brand-asset halves of
// docs/website/THEMING_AND_NAV.md (phases 3-5). The nav builder is phase 7 and
// gets its own screen.
//
// Two things shape this form:
//
// • Every control is a closed set. The presets, the font shortlist and the
// shadow depths all come from GET /settings/theme/options, which is derived
// from the same server config the save is validated against — so the form
// can never offer a value the server would reject. Nothing here is free
// text except the color inputs, which are <input type="color"> and so are
// hex by construction.
// • Saving means writing a settings row; resetting means DELETING it. Absence
// of the row is what selects the shipped default, so "reset" cannot write a
// copy of the defaults — see §2.
// Human labels for the eight editable colors and four radii. The field names
// and the CSS variables they drive both come from the server
// (colorFields / radiusFields); this only decorates them, and a field with no
// label here still renders under its raw name rather than vanishing.
const COLOR_LABELS = {
bg: 'Background',
bgDeep: 'Background (deep)',
panelA: 'Panel (top)',
panelB: 'Panel (bottom)',
accent: 'Accent',
accentBright: 'Accent (bright)',
ink: 'Ink / headings',
text: 'Body text',
}
const RADIUS_LABELS = {
radiusPill: 'Pills & buttons',
radiusPanel: 'Flat panels',
radiusCard: 'Cards & panels',
radiusInput: 'Inputs & notes',
}
const FONT_LABELS = {
serif: 'Body serif',
display: 'Display / headings',
sans: 'Interface sans',
}
// Strip empty groups so a theme the admin cleared back out is stored as a bare
// preset rather than as `{colors:{}, fonts:{}, structure:{}}`. Never null a
// field out to "clear" it — remove it (§6.1).
function compactCustom(custom) {
const out = {}
for (const [group, fields] of Object.entries(custom)) {
const kept = Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== '' && v != null))
if (Object.keys(kept).length) out[group] = kept
}
return Object.keys(out).length ? out : null
}
export default function AppearanceAdmin() {
const { refresh: refreshSite } = useSite()
const [options, setOptions] = useState(null)
const [preset, setPreset] = useState('runic-gateway')
const [custom, setCustom] = useState({ colors: {}, fonts: {}, structure: {} })
// Whether a theme_visual row exists at all. Drives the "reset" button and the
// "this instance is using the shipped theme" note — an admin needs to be able
// to tell "never themed" from "themed to look like the default".
const [stored, setStored] = useState(false)
// The brand-asset overrides, read in the same settings fetch and then owned by
// the panel below (its uploads save on their own, so it does not share this
// screen's Save button).
const [assets, setAssets] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const [saved, setSaved] = useState(false)
useEffect(() => {
let active = true
Promise.all([api.themeOptions(), api.admin.getSettings()])
.then(([opts, all]) => {
if (!active) return
setOptions(opts)
// The stored values are JSON strings (settings.value is TEXT), and a
// malformed one reads as absent exactly as the server treats it — the
// form then shows the shipped default rather than an error.
const parsed = parseJsonSetting(all.theme_visual)
setStored(Boolean(all.theme_visual))
setAssets(parseJsonSetting(all.brand_assets) || {})
if (parsed) {
setPreset(parsed.preset || 'runic-gateway')
setCustom({
colors: parsed.custom?.colors || {},
fonts: parsed.custom?.fonts || {},
structure: parsed.custom?.structure || {},
})
}
})
.catch(() => active && setError('Could not load the appearance settings.'))
.finally(() => active && setLoading(false))
return () => {
active = false
}
}, [])
// What an unset field currently resolves to: the selected preset's palette,
// or the shipped theme when the preset is Custom (which has no base). Lets a
// color picker open on the value the admin is actually looking at.
const baseTokens = useMemo(() => {
if (!options) return {}
return options.presets.find((p) => p.id === preset)?.tokens || options.shippedTokens
}, [options, preset])
if (loading) return <Loading />
if (error && !options) return <ErrorState message={error} />
const setField = (group, field) => (value) => {
setCustom((c) => ({ ...c, [group]: { ...c[group], [field]: value } }))
setSaved(false)
}
const clearField = (group, field) => () => {
setCustom((c) => {
const next = { ...c[group] }
delete next[field]
return { ...c, [group]: next }
})
setSaved(false)
}
async function save() {
setBusy(true)
setError('')
try {
await api.admin.updateSettings({ theme_visual: { preset, custom: compactCustom(custom) } })
setStored(true)
setSaved(true)
// Repull the public settings so the surrounding admin UI re-themes itself
// immediately — the admin sees the change they just made.
await refreshSite()
} catch (err) {
setError(err.message || 'Could not save the theme.')
} finally {
setBusy(false)
}
}
async function resetAll() {
setBusy(true)
setError('')
try {
await api.admin.resetSetting('theme_visual')
setPreset('runic-gateway')
setCustom({ colors: {}, fonts: {}, structure: {} })
setStored(false)
setSaved(false)
await refreshSite()
} catch (err) {
setError(err.message || 'Could not reset the theme.')
} finally {
setBusy(false)
}
}
return (
<section style={{ maxWidth: 720, display: 'flex', flexDirection: 'column', gap: 26 }}>
<p className="sans dim" style={{ margin: 0, fontSize: '0.82rem', lineHeight: 1.7 }}>
Colors, fonts and corner radius for the public site, this admin panel and the player portal.
{' '}
{stored ? (
<>This instance has a saved theme. <strong style={{ color: 'var(--muted)' }}>Reset to default</strong> deletes it and returns to the shipped look.</>
) : (
<>This instance has never been themed, so it uses the shipped look and its <code>BRAND_*</code> accent.</>
)}
</p>
{/* ── Preset ─────────────────────────────────────────────── */}
<div>
<span className="field-label">Preset</span>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, marginTop: 8 }}>
{options.presets.map((p) => (
<button
key={p.id}
type="button"
onClick={() => {
setPreset(p.id)
setSaved(false)
}}
className="sans"
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '10px 14px',
borderRadius: 'var(--radius-input)',
border: `1px solid ${preset === p.id ? 'var(--accent)' : 'var(--line)'}`,
background: preset === p.id ? 'var(--blue)' : 'transparent',
color: preset === p.id ? 'var(--ink)' : 'var(--muted)',
cursor: 'pointer',
fontSize: '0.86rem',
}}
aria-pressed={preset === p.id}
>
{p.tokens ? (
<span style={{ display: 'flex', borderRadius: 4, overflow: 'hidden', border: '1px solid var(--line)' }}>
{['--bg', '--panel-a', '--accent', '--ink'].map((t) => (
<span key={t} style={{ width: 11, height: 18, background: p.tokens[t] }} />
))}
</span>
) : (
<span style={{ width: 44, height: 18, borderRadius: 4, border: '1px dashed var(--line)' }} />
)}
{p.label}
</button>
))}
</div>
<span className="sans dim" style={{ display: 'block', marginTop: 8, fontSize: '0.76rem' }}>
{preset === 'custom'
? 'Custom starts from the shipped theme — only the fields you set below change.'
: 'A preset sets the whole palette. Anything you set below overrides it, field by field.'}
</span>
</div>
{/* ── Colors ─────────────────────────────────────────────── */}
<div>
<span className="field-label">Colors</span>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(210px, 1fr))', gap: 12, marginTop: 8 }}>
{options.colorFields.map(({ name, token }) => {
const set = custom.colors[name] !== undefined
return (
<div key={name} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{/* <input type="color"> has no empty state, so an unset field
shows what it currently resolves to rather than black. */}
<input
type="color"
value={custom.colors[name] || baseTokens[token] || '#000000'}
onChange={(e) => setField('colors', name)(e.target.value)}
aria-label={COLOR_LABELS[name] || name}
style={{ width: 34, height: 30, padding: 0, border: '1px solid var(--line)', borderRadius: 6, background: 'transparent', cursor: 'pointer' }}
/>
<span className="sans" style={{ flex: 1, fontSize: '0.82rem', color: set ? 'var(--ink)' : 'var(--dim)' }}>
{COLOR_LABELS[name] || name}
</span>
{set && (
<button type="button" onClick={clearField('colors', name)} className="sans" title="Follow the preset again" style={linkBtn}>
clear
</button>
)}
</div>
)
})}
</div>
<span className="sans dim" style={{ display: 'block', marginTop: 8, fontSize: '0.76rem' }}>
A color you have not set follows the preset. Live and maintenance status colors are never themed green has to keep meaning live.
</span>
</div>
{/* ── Fonts ──────────────────────────────────────────────── */}
<div>
<span className="field-label">Fonts</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginTop: 8 }}>
{Object.keys(options.fonts).map((role) => (
<label key={role} style={{ display: 'block' }}>
<span className="sans dim" style={{ display: 'block', fontSize: '0.76rem', marginBottom: 4 }}>
{FONT_LABELS[role] || role}
</span>
<select
className="select"
value={custom.fonts[role] || ''}
onChange={(e) => (e.target.value ? setField('fonts', role)(e.target.value) : clearField('fonts', role)())}
>
<option value="">Follow the preset</option>
{options.fonts[role].map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</label>
))}
</div>
</div>
{/* ── Structure ──────────────────────────────────────────── */}
<div>
<span className="field-label">Corners &amp; depth</span>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(210px, 1fr))', gap: 12, marginTop: 8 }}>
{options.radiusFields.map(({ name, token }) => (
<label key={name} style={{ display: 'block' }}>
<span className="sans dim" style={{ display: 'block', fontSize: '0.76rem', marginBottom: 4 }}>
{RADIUS_LABELS[name] || name}
</span>
<input
className="input"
type="number"
min="0"
max={options.radiusMaxPx}
placeholder={(baseTokens[token] || '').replace('px', '')}
value={(custom.structure[name] || '').replace('px', '')}
onChange={(e) =>
e.target.value === ''
? clearField('structure', name)()
: setField('structure', name)(`${Math.min(Math.max(parseInt(e.target.value, 10) || 0, 0), options.radiusMaxPx)}px`)
}
/>
</label>
))}
</div>
<label style={{ display: 'block', marginTop: 12 }}>
<span className="sans dim" style={{ display: 'block', fontSize: '0.76rem', marginBottom: 4 }}>
Card shadow
</span>
<select
className="select"
value={custom.structure.shadowDepth || ''}
onChange={(e) => (e.target.value ? setField('structure', 'shadowDepth')(e.target.value) : clearField('structure', 'shadowDepth')())}
>
<option value="">Follow the preset</option>
{options.shadows.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</label>
</div>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">
{busy ? 'Saving…' : 'Save theme'}
</button>
<button onClick={resetAll} disabled={busy || !stored} className="pill" title={stored ? 'Delete the saved theme' : 'Nothing to reset'}>
Reset to default
</button>
{saved && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>Saved.</span>}
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
</div>
<p className="sans dim" style={{ margin: 0, fontSize: '0.76rem', lineHeight: 1.7 }}>
The accent reaches the mobile app and the Discord bot too both theme themselves from this
sites public branding.
</p>
{/* ── Brand assets ───────────────────────────────────────── */}
<BrandAssetsPanel initial={assets || {}} />
</section>
)
}
const linkBtn = {
border: 'none',
background: 'transparent',
color: 'var(--accent)',
fontSize: '0.72rem',
cursor: 'pointer',
padding: 0,
}

View File

@@ -0,0 +1,213 @@
import { useRef, useState } from 'react'
import { api } from '../../../api/client.js'
import { useSite } from '../../../contexts/SiteContext.jsx'
// Admin · Appearance → Brand assets (docs/website/THEMING_AND_NAV.md §6.3).
//
// Three slots, each an override layer over the matching BRAND_* env value. An
// empty slot is not "no image" — it is "whatever this instance was deployed
// with", which is why every row shows what it currently resolves to rather than
// an empty box.
//
// Unlike the theme form above, an upload SAVES IMMEDIATELY: the file and the
// settings row are written by one request, because an upload that stored a file
// and then waited for a Save press would leave litter in /uploads whenever the
// admin changed their mind. Clearing a slot is the same deal in reverse.
const SLOTS = [
{
id: 'logo',
label: 'Logo',
accept: 'image/png,image/jpeg,image/webp,image/avif,image/gif',
limit: '1 MB',
envVar: 'BRAND_LOGO',
help: 'Shown beside the moon in the site header, the admin sidebar and the player portal, and used as the link preview image when a page is shared.',
},
{
id: 'hero',
label: 'Hero image',
accept: 'image/png,image/jpeg,image/webp,image/avif,image/gif',
limit: '8 MB',
envVar: 'BRAND_HERO',
// §4.9: the hero editor's own background beats this, and an admin who does
// not know that files a bug against a working system.
help: 'The image behind the portal hero. If the hero editor has its own background image set, that wins over this one.',
},
{
id: 'favicon',
label: 'Favicon',
accept: 'image/png',
limit: '512 KB',
envVar: 'BRAND_FAVICON',
// §4.10: .ico would mean adding a type to the upload allowlist, and the
// stored extension coming from that allowlist is what makes uploads safe.
help: 'The browser tab icon. PNG only — a 32×32 or 64×64 square works everywhere.',
},
]
export default function BrandAssetsPanel({ initial }) {
const { brand, refresh: refreshSite } = useSite()
const [assets, setAssets] = useState(initial || {})
const [busySlot, setBusySlot] = useState('')
const [error, setError] = useState('')
const inputs = useRef({})
async function upload(slot, file) {
if (!file) return
setBusySlot(slot)
setError('')
try {
const res = await api.admin.uploadBrandAsset(slot, file)
setAssets(res.brand_assets || {})
await refreshSite()
} catch (err) {
setError(err.message || 'Could not upload that image.')
} finally {
setBusySlot('')
// Let the same file be picked again after a failure — a file input holds
// its value, so re-choosing it would fire no change event.
if (inputs.current[slot]) inputs.current[slot].value = ''
}
}
async function clear(slot) {
setBusySlot(slot)
setError('')
try {
const next = { ...assets }
delete next[slot]
// Clearing the last override deletes the row rather than storing `{}` —
// absence of the row is what selects the env defaults (§2), and a stored
// empty object would be a different state that means the same thing.
if (Object.keys(next).length) await api.admin.updateSettings({ brand_assets: next })
else await api.admin.resetSetting('brand_assets')
setAssets(next)
await refreshSite()
} catch (err) {
setError(err.message || 'Could not clear that asset.')
} finally {
setBusySlot('')
}
}
return (
<div>
<span className="field-label">Brand assets</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, marginTop: 8 }}>
{SLOTS.map((slot) => {
const overridden = Boolean(assets[slot.id])
// What the site actually uses right now: the override, or the env
// value the brand block already resolved for us.
const effective = assets[slot.id] || brand[slot.id] || ''
return (
<div
key={slot.id}
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 14,
padding: 12,
border: '1px solid var(--line)',
borderRadius: 'var(--radius-input)',
}}
>
<div
style={{
width: 76,
height: 48,
flex: '0 0 auto',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '1px solid var(--line-soft)',
borderRadius: 6,
background: 'var(--bg-deep)',
overflow: 'hidden',
}}
>
{effective ? (
<img src={effective} alt="" style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }} />
) : (
<span className="sans dim" style={{ fontSize: '0.68rem' }}>
none
</span>
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="sans" style={{ fontSize: '0.86rem', color: 'var(--ink)' }}>
{slot.label}
</div>
<div className="sans dim" style={{ fontSize: '0.74rem', lineHeight: 1.6, marginTop: 2 }}>
{slot.help}
</div>
<div className="sans dim" style={{ fontSize: '0.72rem', marginTop: 6 }}>
{overridden ? (
<>
Uploaded override <code>{assets[slot.id]}</code>
</>
) : effective ? (
<>
Using the deployed default from <code>{slot.envVar}</code>
</>
) : (
<>
Not set <code>{slot.envVar}</code> is empty, so nothing is rendered
</>
)}
</div>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginTop: 8, flexWrap: 'wrap' }}>
<input
ref={(el) => {
inputs.current[slot.id] = el
}}
type="file"
accept={slot.accept}
disabled={Boolean(busySlot)}
onChange={(e) => upload(slot.id, e.target.files?.[0])}
className="sans"
style={{ fontSize: '0.74rem', maxWidth: 240 }}
aria-label={`Upload a ${slot.label.toLowerCase()}`}
/>
<span className="sans dim" style={{ fontSize: '0.7rem' }}>
max {slot.limit}
</span>
{overridden && (
<button
type="button"
onClick={() => clear(slot.id)}
disabled={Boolean(busySlot)}
className="sans"
title={`Go back to ${slot.envVar}`}
style={linkBtn}
>
{busySlot === slot.id ? 'working…' : 'clear'}
</button>
)}
</div>
</div>
</div>
)
})}
</div>
{error && (
<span className="sans" style={{ display: 'block', marginTop: 8, color: '#d98b84', fontSize: '0.85rem' }}>
{error}
</span>
)}
<span className="sans dim" style={{ display: 'block', marginTop: 8, fontSize: '0.76rem' }}>
Uploads apply as soon as they finish there is nothing to save here. The footers powered by
Runic Gateway mark is the projects badge, not this instances, and never changes.
</span>
</div>
)
}
const linkBtn = {
border: 'none',
background: 'transparent',
color: 'var(--accent)',
fontSize: '0.72rem',
cursor: 'pointer',
padding: 0,
}

View File

@@ -4,9 +4,15 @@ import { useAsync } from '../../../lib/useAsync.js'
import { ago, dateTime } from '../../../lib/format.js' import { ago, dateTime } from '../../../lib/format.js'
import { api } from '../../../api/client.js' import { api } from '../../../api/client.js'
import { useSite } from '../../../contexts/SiteContext.jsx' import { useSite } from '../../../contexts/SiteContext.jsx'
import { useAuth } from '../../../contexts/AuthContext.jsx'
export default function Dashboard() { export default function Dashboard() {
const { refresh: refreshSite } = useSite() const { refresh: refreshSite } = useSite()
const { user } = useAuth()
// PUT /admin/site-mode is adminOnly. The dashboard itself is staff-wide, so the
// toggle needs its own gate — same rule the sidebar follows (AdminLayout: never
// show a non-admin a control that would 403).
const isAdmin = user?.role === 'admin'
const [tick, setTick] = useState(0) const [tick, setTick] = useState(0)
const reload = useCallback(() => setTick((t) => t + 1), []) const reload = useCallback(() => setTick((t) => t + 1), [])
@@ -15,6 +21,7 @@ export default function Dashboard() {
[tick], [tick],
) )
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [modeError, setModeError] = useState('')
if (loading) return <Loading /> if (loading) return <Loading />
if (error) return <ErrorState message="Could not load the dashboard." /> if (error) return <ErrorState message="Could not load the dashboard." />
@@ -32,12 +39,21 @@ export default function Dashboard() {
{ value: dash.counts?.users ?? 0, label: 'Users' }, { value: dash.counts?.users ?? 0, label: 'Users' },
] ]
// The rejection was previously unhandled: a refused toggle surfaced only as an
// unhandled promise rejection in the console while the button silently reverted.
async function toggle() { async function toggle() {
setBusy(true) setBusy(true)
setModeError('')
try { try {
await api.admin.setSiteMode(isLive ? 'maintenance' : 'live') await api.admin.setSiteMode(isLive ? 'maintenance' : 'live')
await refreshSite() await refreshSite()
reload() reload()
} catch (err) {
setModeError(
err.status === 403
? 'Only an administrator can change the site mode.'
: 'Could not change the site mode. Try again.',
)
} finally { } finally {
setBusy(false) setBusy(false)
} }
@@ -78,7 +94,13 @@ export default function Dashboard() {
{changed.by ? `Changed by ${changed.by}` : 'No changes recorded'} {changed.by ? `Changed by ${changed.by}` : 'No changes recorded'}
{changed.at ? ` · ${dateTime(changed.at)}` : ''} {changed.at ? ` · ${dateTime(changed.at)}` : ''}
</div> </div>
{modeError && (
<div className="sans" style={{ fontSize: '0.8rem', marginTop: 8, color: 'var(--danger, #d98b8b)' }}>
{modeError}
</div> </div>
)}
</div>
{isAdmin && (
<button <button
onClick={toggle} onClick={toggle}
disabled={busy} disabled={busy}
@@ -87,6 +109,7 @@ export default function Dashboard() {
> >
{modeLabel} {modeLabel}
</button> </button>
)}
</div> </div>
<div className="grid-4" style={{ gap: 14, marginBottom: 28 }}> <div className="grid-4" style={{ gap: 14, marginBottom: 28 }}>

View File

@@ -1,119 +0,0 @@
import { useMemo, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useAsync } from '../../../lib/useAsync.js'
import { useShardFeed } from '../../../lib/useShardFeed.js'
import { api } from '../../../api/client.js'
// Staff-only FULL house registry (admin + moderator). Owner, price, co-owners and
// decay — everything the public board hides. Loaded from /admin/shard/houses, kept
// live from the admin SSE channel (house.update / house.remove).
const HOUSE_KINDS = new Set(['house.update', 'house.remove', 'house.decay'])
const DECAY_TONE = {
LikeNew: '#7fd0a4', Ageless: '#7fd0a4', Slightly: '#a9cf8a', Somewhat: '#d7c56a',
Fairly: '#e0a95f', Greatly: '#d9736f', IDOC: '#e05a5a', Collapsed: '#8c96a5',
}
function DecayBadge({ decay, isIdoc }) {
const label = isIdoc ? 'IDOC' : decay
if (!label) return null
const tone = DECAY_TONE[label] || 'var(--muted)'
return (
<span className="sans" style={{ flex: 'none', fontSize: '0.68rem', color: tone, border: `1px solid ${tone}66`, borderRadius: 999, padding: '2px 8px' }}>
{label}
</span>
)
}
function ownerLabel(h) {
return h.ownerName || h.ownerAcct || null
}
function HouseRow({ h }) {
const owner = ownerLabel(h)
return (
<div className="panel" style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<strong className="display" style={{ fontSize: '1rem', color: 'var(--head)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{h.name || 'An unnamed house'}
</strong>
<DecayBadge decay={h.decay} isIdoc={h.isIdoc} />
</div>
<div className="sans dim" style={{ fontSize: '0.78rem', marginTop: 3 }}>
{owner ? <>Owned by <span style={{ color: 'var(--ink)' }}>{owner}</span></> : 'No owner'}
{(h.coOwners || h.friends) ? ` · ${h.coOwners || 0} co-owners, ${h.friends || 0} friends` : ''}
</div>
<div className="sans dim" style={{ fontSize: '0.72rem', marginTop: 2 }}>
{h.region || h.map || '—'}{h.x != null ? ` (${h.x}, ${h.y})` : ''}
</div>
</div>
{h.price != null && (
<div className="sans" style={{ flex: 'none', textAlign: 'right' }}>
<div style={{ fontSize: '0.92rem', color: 'var(--head)', fontVariantNumeric: 'tabular-nums' }}>{Number(h.price).toLocaleString()}</div>
<div className="dim" style={{ fontSize: '0.64rem', letterSpacing: '0.04em', textTransform: 'uppercase' }}>placement value</div>
</div>
)}
</div>
)
}
export default function HousesAdmin() {
const { loading, error, data } = useAsync(() => api.admin.shard.houses())
// Full registry deltas ride the admin SSE channel (never the public one).
const { events, connected } = useShardFeed({ url: api.adminShardStreamUrl, filter: HOUSE_KINDS, max: 80 })
const [q, setQ] = useState('')
const board = useMemo(() => {
const map = new Map()
for (const h of data || []) if (h && h.serial) map.set(h.serial, h)
for (let i = events.length - 1; i >= 0; i -= 1) {
const ev = events[i]
if (!ev.serial) continue
if (ev.kind === 'house.update') {
map.set(ev.serial, { ...ev, ownerName: ev.owner?.name ?? ev.ownerName, ownerAcct: ev.owner?.acct ?? ev.ownerAcct })
} else if (ev.kind === 'house.remove') {
map.delete(ev.serial)
} else if (ev.kind === 'house.decay') {
const cur = map.get(ev.serial) || { serial: ev.serial, name: ev.name, region: ev.region, map: ev.map, x: ev.x, y: ev.y }
map.set(ev.serial, { ...cur, isIdoc: String(ev.to).toUpperCase() === 'IDOC' })
}
}
return [...map.values()]
}, [data, events])
const filtered = useMemo(() => {
const needle = q.trim().toLowerCase()
const rows = needle
? board.filter((h) => [h.name, h.region, h.map, ownerLabel(h)].some((v) => v && String(v).toLowerCase().includes(needle)))
: board
return [...rows].sort((a, b) => (a.name || '').localeCompare(b.name || ''))
}, [board, q])
if (loading) return <Loading />
if (error) return <ErrorState message="Could not load the house registry." />
return (
<section>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 16 }}>
<p className="sans" style={{ color: 'var(--accent)', fontSize: '0.82rem', margin: 0 }}>
{board.length.toLocaleString()} houses
<span className="dim" style={{ marginLeft: 10, color: connected ? '#7fd0a4' : 'var(--muted)' }}>{connected ? '● live' : '○ offline'}</span>
</p>
<input className="input sans" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search by owner, region…" style={{ flex: 'none', width: 230, maxWidth: '55%', fontSize: '0.84rem' }} />
</div>
{board.length === 0 ? (
<div className="panel" style={{ padding: 24, textAlign: 'center' }}>
<p className="sans dim" style={{ margin: 0 }}>No houses are being tracked right now.</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{filtered.map((h) => <HouseRow key={h.serial} h={h} />)}
</div>
)}
{board.length > 0 && filtered.length === 0 && (
<p className="sans dim" style={{ textAlign: 'center', marginTop: 20 }}>No houses match {q}.</p>
)}
</section>
)
}

View File

@@ -0,0 +1,561 @@
import { useEffect, useMemo, useState } from 'react'
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'
import {
SortableContext,
arrayMove,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
import { useAuth } from '../../../contexts/AuthContext.jsx'
import { useSite } from '../../../contexts/SiteContext.jsx'
import { withModuleNav } from '../../../modules/nav.js'
import { useFeatureGate } from '../../../modules/features.jsx'
import { buildNavRows, buildNavOverrides, buildPublicNav, buildPublicNavOverrides } from '../../../lib/navOverrides.js'
import PublicNavTree from './PublicNavTree.jsx'
import { parseJsonSetting } from '../../../lib/settingsJson.js'
import { refreshNavOverrides } from '../../../lib/useNavOverrides.js'
import { NAV as PUBLIC_NAV } from '../../../components/SiteHeader.jsx'
import { NAV as ADMIN_NAV, navItemVisibleTo } from '../AdminLayout.jsx'
import { NAV as PLAYER_NAV } from '../../player/PlayerPortalLayout.jsx'
// Admin · Navigation — phases 6-8 of docs/website/THEMING_AND_NAV.md.
//
// The three navs stay declared in code, each in the component that renders it;
// this screen writes an override *layer* over them (§7). It can relabel,
// reorder, hide and — on the admin sidebar — move a row into another existing
// section, and nothing else. It cannot introduce a route and it cannot touch a
// `roles` or `feature` gate, so the filters in the layouts still decide who sees
// what, and they run after the merge.
//
// Three things shape the screen:
//
// • The palette is filtered to the editing admin's OWN visible rows (§8.1) —
// the base array run through their role and the feature gates of whichever
// module registered each row (client/src/modules/featureGate.js). An
// admin cannot drag in, and so can never accidentally advertise, something
// they cannot see themselves. An override on a row they cannot see is
// carried through their save untouched rather than quietly reset.
// • The rows come from the same merge the site renders (buildNavRows), hidden
// ones included, so the editor cannot show an order the nav does not use.
// • Saving writes a settings row; "reset" DELETES it. Absence of the row is
// what selects the coded default, so reset cannot store a copy of it — and a
// save whose result is empty deletes the row for the same reason (§4.1).
// The nav editor's own row. Hiding it would remove the only screen that can
// un-hide it, so its eye toggle is disabled here and the server drops `hidden`
// on it as well (server/src/utils/navOverrides.js) — a hand-written row cannot
// do what the UI refuses.
const SELF = '/admin/navigation'
const TABS = [
{ key: 'nav_public', label: 'Public site', hint: 'The header on every public page.' },
{ key: 'nav_admin', label: 'Admin', hint: 'This sidebar. Rows can also move between sections.' },
{ key: 'nav_player', label: 'Player portal', hint: 'The sidebar a signed-in player sees.' },
]
function DragHandle({ attributes, listeners, disabled }) {
return (
<button
type="button"
className="sans"
aria-label="Reorder"
disabled={disabled}
{...attributes}
{...listeners}
style={{
border: 'none',
background: 'transparent',
color: 'var(--dim)',
cursor: disabled ? 'default' : 'grab',
padding: '2px 4px',
touchAction: 'none',
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false">
<circle cx="9" cy="6" r="1.6" />
<circle cx="15" cy="6" r="1.6" />
<circle cx="9" cy="12" r="1.6" />
<circle cx="15" cy="12" r="1.6" />
<circle cx="9" cy="18" r="1.6" />
<circle cx="15" cy="18" r="1.6" />
</svg>
</button>
)
}
function EyeIcon({ off }) {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" focusable="false">
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" />
<circle cx="12" cy="12" r="3" />
{off && <path d="M3 3l18 18" />}
</svg>
)
}
/**
* One editable nav row, shared by all three tabs.
*
* The destination control is generic because the two navs that have one mean
* different things by it: the admin sidebar moves rows between the four coded
* sections, the public header between admin-created dropdowns. Both are "pick a
* container", so both get one `<select>` rather than cross-container dragging —
* which is a lot of interaction surface for something an admin does once.
*
* @param {Array<{value: string, label: string}>} [destinations] omit for a nav
* with no containers (the player portal)
* @param {() => void} [onDelete] only an admin-authored link can be deleted;
* a coded row is hidden, never removed
*/
export function Row({ row, id, destinations, destination, onDestination, onChange, onDelete }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id })
const renamed = row.defaultLabel !== undefined && row.label !== row.defaultLabel
const locked = row.to === SELF
return (
<li
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '7px 10px',
borderRadius: 'var(--radius-input)',
border: '1px solid var(--line)',
background: isDragging ? 'var(--blue)' : 'var(--panel-flat)',
opacity: row.hidden ? 0.55 : 1,
listStyle: 'none',
}}
>
<DragHandle attributes={attributes} listeners={listeners} />
<input
className="input"
value={row.label}
placeholder={row.defaultLabel || row.to}
maxLength={64}
onChange={(e) => onChange({ ...row, label: e.target.value })}
aria-label={`Label for ${row.defaultLabel || row.to}`}
style={{ flex: '1 1 auto', minWidth: 120, padding: '5px 8px', fontSize: '0.84rem' }}
/>
{/* The route, for orientation — it is what the override is keyed by. Fixed
and truncating rather than flexible: /admin/moderation/appeals would
otherwise wrap and squeeze the label input it sits beside. */}
<code
className="sans dim"
title={row.to}
style={{
flex: '0 0 auto',
width: 130,
fontSize: '0.7rem',
opacity: 0.75,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
textAlign: 'right',
}}
>
{row.to}
</code>
{renamed && (
<button
type="button"
className="sans"
title="Use the coded label again"
onClick={() => onChange({ ...row, label: row.defaultLabel })}
style={{ border: 'none', background: 'transparent', color: 'var(--accent)', fontSize: '0.72rem', cursor: 'pointer', padding: 0 }}
>
reset
</button>
)}
{destinations && destinations.length > 0 && (
<select
className="select"
value={destination ?? ''}
onChange={(e) => onDestination(e.target.value || null)}
aria-label={`Section for ${row.defaultLabel || row.to}`}
style={{ flex: '0 0 auto', width: 130, padding: '4px 6px', fontSize: '0.76rem' }}
>
{destinations.map((d) => (
<option key={d.value} value={d.value}>
{d.label}
</option>
))}
</select>
)}
{onDelete && (
<button
type="button"
className="sans"
title="Remove this link"
onClick={onDelete}
style={{
border: '1px solid var(--line)',
borderRadius: 'var(--radius-input)',
background: 'transparent',
color: 'var(--muted)',
cursor: 'pointer',
padding: '4px 8px',
fontSize: '0.76rem',
}}
>
×
</button>
)}
{/* A coded row is hidden, never removed — the route still exists. An
admin-authored link is the opposite: there is nothing to fall back to,
so it is deleted instead (the × above). */}
{!onDelete && (
<button
type="button"
className="sans"
disabled={locked}
title={
locked
? 'This screen is the only way back — it cannot be hidden'
: row.hidden
? 'Currently hidden. Show it again'
: 'Hide from this nav'
}
aria-pressed={row.hidden}
onClick={() => onChange({ ...row, hidden: !row.hidden })}
style={{
border: '1px solid var(--line)',
borderRadius: 'var(--radius-input)',
background: 'transparent',
color: locked ? 'var(--dim)' : row.hidden ? 'var(--accent)' : 'var(--muted)',
cursor: locked ? 'not-allowed' : 'pointer',
padding: '4px 6px',
display: 'flex',
opacity: locked ? 0.5 : 1,
}}
>
<EyeIcon off={row.hidden} />
</button>
)}
</li>
)
}
export default function NavEditor() {
const { user } = useAuth()
const { refresh: refreshSite } = useSite()
const isVisible = useFeatureGate()
const [tab, setTab] = useState('nav_public')
// Per nav: the editable groups, the overrides as loaded (so a row this admin
// cannot see survives their save), and whether a settings row exists at all.
const [state, setState] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const [saved, setSaved] = useState('')
const [dirty, setDirty] = useState({})
// The nav as coded, unfiltered. The palette below is what this admin may EDIT;
// this is what still EXISTS, and the two are different questions. Saving needs
// both: an entry for a row their palette filtered out must be carried through
// rather than reset, and only an entry for a route the code no longer declares
// at all should be dropped.
//
// Each nav is the coded array with every installed module's rows already
// interleaved (modules/nav.js) — the same array the layout renders, which is
// what makes a module row editable here at all: the override merge is keyed by
// `to` and drops a key the base it is handed does not declare, so a nav built
// from core alone would silently discard every stored override on a module row
// the moment it was saved.
const fullNavs = useMemo(
() => ({
nav_public: withModuleNav(PUBLIC_NAV, 'public'),
nav_admin: withModuleNav(ADMIN_NAV, 'admin'),
nav_player: withModuleNav(PLAYER_NAV, 'player'),
}),
[],
)
// The palette: each base nav, filtered to what THIS admin can see (§8.1). Two
// gates, and neither is core's own opinion any more — `roles` on a row, and
// the owning module's answer for a row that names a `feature`.
const palettes = useMemo(
() => ({
nav_public: fullNavs.nav_public.filter(isVisible),
nav_admin: fullNavs.nav_admin
.map((g) => ({ ...g, items: g.items.filter((i) => navItemVisibleTo(i, user?.role) && isVisible(i)) }))
.filter((g) => g.items.length > 0),
nav_player: fullNavs.nav_player.filter(isVisible),
}),
[fullNavs, isVisible, user?.role],
)
useEffect(() => {
let active = true
api.admin
.getSettings()
.then((all) => {
if (!active) return
const next = {}
for (const { key } of TABS) {
const stored = parseJsonSetting(all[key])
// The public header is a tree (sections are entries in the top-level
// order); the other two are the fixed-frame grouped/flat shape.
next[key] =
key === 'nav_public'
? { stored, hasRow: Boolean(all[key]), tree: buildPublicNav(palettes[key], stored, { keepHidden: true }) }
: { stored, hasRow: Boolean(all[key]), groups: buildNavRows(palettes[key], stored) }
}
setState(next)
})
.catch(() => active && setError('Could not load the navigation settings.'))
.finally(() => active && setLoading(false))
return () => {
active = false
}
// Loaded once; the palettes settle before the fetch resolves in practice, and
// re-running on a feature flip would discard the admin's unsaved edits.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
)
if (loading) return <Loading />
if (error && !state) return <ErrorState message={error} />
const current = state[tab]
const isPublic = tab === 'nav_public'
const groupTitles = isPublic ? [] : current.groups.map((g) => g.title).filter(Boolean)
// Where each row is declared in code, so the section dropdown can offer only
// the destinations an override is able to express.
const baseGroups = new Map(
(!isPublic && Array.isArray(palettes[tab]) && palettes[tab][0]?.items
? palettes[tab].flatMap((g) => g.items.map((i) => [i.to, g.title ?? null]))
: []),
)
// The admin sidebar can only move a row between the four coded sections, and
// "(no section)" only for a row coded into an untitled one — for anything else
// it is a move an override cannot express (§6.4), so offering it would
// silently do nothing.
const groupDestinations = (baseGroup) => [
...(baseGroup === null ? [{ value: '', label: '(no section)' }] : []),
...groupTitles.map((t) => ({ value: t, label: t })),
]
function mutate(updater) {
setState((s) => ({ ...s, [tab]: { ...s[tab], groups: updater(s[tab].groups) } }))
setDirty((d) => ({ ...d, [tab]: true }))
setSaved('')
}
function setTree(tree) {
setState((s) => ({ ...s, [tab]: { ...s[tab], tree } }))
setDirty((d) => ({ ...d, [tab]: true }))
setSaved('')
}
const onRowChange = (next) =>
mutate((groups) => groups.map((g) => ({ ...g, items: g.items.map((i) => (i.to === next.to ? next : i)) })))
// Sections change by dropdown, not by dragging: a drag that could land in
// another list is a lot of interaction surface for something an admin does
// once, and this keeps every drag a simple reorder. The row goes to the end of
// its new section, where it is visible and can then be dragged into place.
const onMoveGroup = (to, title) =>
mutate((groups) => {
const moving = groups.flatMap((g) => g.items).find((i) => i.to === to)
if (!moving) return groups
return groups.map((g) => {
if ((g.title ?? null) === title) return { ...g, items: [...g.items.filter((i) => i.to !== to), moving] }
return { ...g, items: g.items.filter((i) => i.to !== to) }
})
})
const onDragEnd = (groupIndex) => (event) => {
const { active, over } = event
if (!over || active.id === over.id) return
mutate((groups) =>
groups.map((g, i) => {
if (i !== groupIndex) return g
const from = g.items.findIndex((it) => it.to === active.id)
const to = g.items.findIndex((it) => it.to === over.id)
if (from < 0 || to < 0) return g
return { ...g, items: arrayMove(g.items, from, to) }
}),
)
}
// Push a save into whatever is rendering that nav right now, so the admin sees
// what they just did: the header re-reads the public settings, the two
// authenticated sidebars re-read /settings/nav.
async function propagate(key) {
if (key === 'nav_public') await refreshSite()
else await refreshNavOverrides()
}
async function save() {
setBusy(true)
setError('')
try {
const overrides = isPublic
? buildPublicNavOverrides(current.tree, fullNavs[tab], current.stored)
: buildNavOverrides(current.groups, fullNavs[tab], current.stored)
// A wrapper with an empty `items` and no sections/links says nothing
// either, so "empty" is about the whole value, not just its key count.
const empty =
Object.keys(overrides).length === 0 ||
(overrides.items !== undefined &&
Object.keys(overrides.items).length === 0 &&
!overrides.sections?.length &&
!overrides.links?.length)
// Nothing differs from the code default, so there is nothing to store —
// and a row that says nothing would still read as "this nav was
// customised". Delete it instead (§2, §4.1).
if (empty) await api.admin.resetSetting(tab)
else await api.admin.updateSettings({ [tab]: overrides })
setState((s) => ({
...s,
[tab]: { ...s[tab], stored: empty ? null : overrides, hasRow: !empty },
}))
setDirty((d) => ({ ...d, [tab]: false }))
setSaved(tab)
await propagate(tab)
} catch (err) {
setError(err.message || 'Could not save this navigation.')
} finally {
setBusy(false)
}
}
async function resetNav() {
setBusy(true)
setError('')
try {
await api.admin.resetSetting(tab)
setState((s) => ({
...s,
[tab]: isPublic
? { stored: null, hasRow: false, tree: buildPublicNav(palettes[tab], null, { keepHidden: true }) }
: { stored: null, hasRow: false, groups: buildNavRows(palettes[tab], null) },
}))
setDirty((d) => ({ ...d, [tab]: false }))
setSaved('')
await propagate(tab)
} catch (err) {
setError(err.message || 'Could not reset this navigation.')
} finally {
setBusy(false)
}
}
const activeTab = TABS.find((t) => t.key === tab)
return (
<section style={{ maxWidth: 860, display: 'flex', flexDirection: 'column', gap: 22 }}>
<p className="sans dim" style={{ margin: 0, fontSize: '0.82rem', lineHeight: 1.7 }}>
Rename, reorder and hide the entries in each navigation. The pages themselves are unchanged this
only decides what is advertised, and it can never show anyone a link their role, or the visibility
settings of an installed module, would hide.
</p>
{/* ── Tabs ───────────────────────────────────────────────── */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{TABS.map((t) => (
<button
key={t.key}
type="button"
className="sans"
onClick={() => {
setTab(t.key)
setSaved('')
}}
aria-pressed={tab === t.key}
style={{
padding: '8px 14px',
borderRadius: 'var(--radius-input)',
border: `1px solid ${tab === t.key ? 'var(--accent)' : 'var(--line)'}`,
background: tab === t.key ? 'var(--blue)' : 'transparent',
color: tab === t.key ? 'var(--ink)' : 'var(--muted)',
cursor: 'pointer',
fontSize: '0.86rem',
}}
>
{t.label}
{dirty[t.key] && <span style={{ color: 'var(--accent)' }}> </span>}
</button>
))}
</div>
<span className="sans dim" style={{ fontSize: '0.76rem' }}>
{activeTab.hint}{' '}
{current.hasRow
? 'This nav has saved overrides.'
: 'This nav has never been customised, so it renders exactly as coded.'}
</span>
{/* ── Rows ───────────────────────────────────────────────── */}
{/* The public header gets its own editor: a section there is an entry in
the top-level order that an admin created, not a fixed frame the code
declares, so it is a tree rather than a list of groups. */}
{isPublic ? (
<PublicNavTree tree={current.tree} onChange={setTree} />
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
{current.groups.map((group, groupIndex) => (
<div key={group.title ?? `group-${groupIndex}`}>
{group.title && <span className="field-label">{group.title}</span>}
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd(groupIndex)}>
<SortableContext items={group.items.map((i) => i.to)} strategy={verticalListSortingStrategy}>
<ul style={{ display: 'flex', flexDirection: 'column', gap: 6, margin: '8px 0 0', padding: 0 }}>
{group.items.map((row) => (
<Row
key={row.to}
id={row.to}
row={row}
destinations={groupTitles.length > 0 ? groupDestinations(baseGroups.get(row.to) ?? null) : null}
destination={group.title ?? ''}
onDestination={(value) => onMoveGroup(row.to, value)}
onChange={onRowChange}
/>
))}
{group.items.length === 0 && (
<li className="sans dim" style={{ fontSize: '0.76rem', listStyle: 'none', padding: '6px 2px' }}>
Empty this section is not rendered until something is moved into it.
</li>
)}
</ul>
</SortableContext>
</DndContext>
</div>
))}
</div>
)}
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">
{busy ? 'Saving…' : 'Save navigation'}
</button>
<button
onClick={resetNav}
disabled={busy || !current.hasRow}
className="pill"
title={current.hasRow ? 'Delete the saved overrides for this nav' : 'Nothing to reset'}
>
Reset to default
</button>
{saved === tab && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>Saved.</span>}
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
</div>
<p className="sans dim" style={{ margin: 0, fontSize: '0.76rem', lineHeight: 1.7 }}>
Only entries you can see yourself are listed. Anything hidden from you by your role, or by a
module&rsquo;s visibility settings, keeps whatever it was already set to.
</p>
</section>
)
}

View File

@@ -177,14 +177,15 @@ const delStyle = {
} }
// ── Announcement status panel ──────────────────────────────────────────────── // ── Announcement status panel ────────────────────────────────────────────────
// Shows the town-crier + Discord delivery state for a published news post and // Shows each delivery leg's state for a published news post and offers a per-leg
// offers a per-leg retry (useful after fixing the sidecar / news channel without // retry (useful after fixing the sidecar / news channel without re-publishing).
// re-publishing). Only rendered for news posts in edit mode; renders nothing // Only rendered for news posts in edit mode; renders nothing until the post has
// until the post has actually been announced (no job row yet → nothing to show). // actually been announced (no job row yet → nothing to show).
const LEG_META = { //
towncrier: { label: 'In-game town crier' }, // The legs and their labels come from the JOB, not from a constant here: which
discord: { label: 'Discord #news' }, // legs exist is decided by what the server has registered, so an installed module
} // brings its own leg and this panel renders it with no client change
// (docs/website/MODULE_SYSTEM.md §1.8).
const STATUS_STYLE = { const STATUS_STYLE = {
done: { color: '#7bbf8f', label: 'delivered' }, done: { color: '#7bbf8f', label: 'delivered' },
pending: { color: '#d9b84a', label: 'pending' }, pending: { color: '#d9b84a', label: 'pending' },
@@ -227,14 +228,12 @@ function AnnouncePanel({ postId }) {
return ( return (
<div style={panelStyle}> <div style={panelStyle}>
<span className="field-label" style={{ marginBottom: 2 }}>Announcement</span> <span className="field-label" style={{ marginBottom: 2 }}>Announcement</span>
{['towncrier', 'discord'].map((leg) => { {(job.legs || []).map(({ leg, label, status, last_error: err }) => {
const status = job[`${leg}_status`]
const err = job[`${leg}_last_error`]
const s = STATUS_STYLE[status] || STATUS_STYLE.pending const s = STATUS_STYLE[status] || STATUS_STYLE.pending
return ( return (
<div key={leg} style={{ display: 'flex', flexDirection: 'column', gap: 3 }}> <div key={leg} style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="sans" style={{ fontSize: '0.85rem', minWidth: 140 }}>{LEG_META[leg].label}</span> <span className="sans" style={{ fontSize: '0.85rem', minWidth: 140 }}>{label}</span>
<span className="sans" style={{ fontSize: '0.8rem', color: s.color, fontWeight: 600 }}> {s.label}</span> <span className="sans" style={{ fontSize: '0.8rem', color: s.color, fontWeight: 600 }}> {s.label}</span>
{status !== 'done' && ( {status !== 'done' && (
<button <button

View File

@@ -0,0 +1,310 @@
import { useState } from 'react'
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'
import {
SortableContext,
arrayMove,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import Modal from '../../../components/Modal.jsx'
import { Row } from './NavEditor.jsx'
// The Public tab of Admin → Navigation (THEMING_AND_NAV.md §7, Phase 10).
//
// The public header is the one nav an admin can restructure rather than only
// reorder, so it needs its own editor: a **section is itself an entry in the
// top-level order**, which the fixed coded sections of the admin sidebar never
// are. That is the whole reason this is not the grouped editor with a different
// label — there, groups are a fixed frame and only membership moves.
//
// The tree is `[{kind: 'item' | 'link' | 'section', ...}]`, one level deep, and
// comes from the same `buildPublicNav` the header renders, so what an admin
// drags is what visitors get.
const uid = (prefix) => `${prefix}_${Math.random().toString(36).slice(2, 10)}`
// A path on this site, matching what the server will accept. Checked here so the
// admin gets the message while the field is in front of them; the server's 400
// stays the backstop, not the first feedback.
export function badLinkPath(value) {
const v = (value || '').trim()
if (!v) return 'Enter a path.'
if (/^[a-z][a-z0-9+.-]*:/i.test(v) || v.startsWith('//')) {
return 'Links must point somewhere on this site — start with “/”.'
}
if (!v.startsWith('/')) return 'Start the path with “/”, for example /wiki/new-player-guide.'
if (/[\s<>"'\\]/.test(v)) return 'A path cannot contain spaces or quotes.'
if (v.length > 128) return 'That path is too long.'
return null
}
function SectionCard({ section, index, children, onChange, onDelete }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: section.id })
return (
<li
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
listStyle: 'none',
border: '1px solid var(--line)',
borderRadius: 'var(--radius-card)',
background: isDragging ? 'var(--blue)' : 'transparent',
padding: 10,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button
type="button"
className="sans"
aria-label={`Reorder ${section.label}`}
{...attributes}
{...listeners}
style={{ border: 'none', background: 'transparent', color: 'var(--dim)', cursor: 'grab', padding: '2px 4px', touchAction: 'none' }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false">
<circle cx="9" cy="6" r="1.6" /><circle cx="15" cy="6" r="1.6" />
<circle cx="9" cy="12" r="1.6" /><circle cx="15" cy="12" r="1.6" />
<circle cx="9" cy="18" r="1.6" /><circle cx="15" cy="18" r="1.6" />
</svg>
</button>
<input
className="input"
value={section.label}
maxLength={64}
placeholder="Section name"
onChange={(e) => onChange({ ...section, label: e.target.value })}
aria-label={`Name for section ${index + 1}`}
style={{ flex: '1 1 auto', minWidth: 120, padding: '5px 8px', fontSize: '0.84rem', fontWeight: 600 }}
/>
<span className="sans dim" style={{ fontSize: '0.7rem' }}>dropdown</span>
<button
type="button"
className="sans"
title="Delete this section — the entries inside move back out, they are not removed"
onClick={onDelete}
style={{
border: '1px solid var(--line)',
borderRadius: 'var(--radius-input)',
background: 'transparent',
color: 'var(--muted)',
cursor: 'pointer',
padding: '4px 8px',
fontSize: '0.76rem',
}}
>
×
</button>
</div>
{children}
</li>
)
}
export default function PublicNavTree({ tree, onChange }) {
const [adding, setAdding] = useState(null) // {label, to, error} while the modal is open
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
)
const sections = tree.filter((n) => n.kind === 'section')
const destinations = [{ value: '', label: 'Top level' }, ...sections.map((s) => ({ value: s.id, label: s.label || 'Section' }))]
const keyOf = (node) => (node.kind === 'item' ? node.to : node.id)
// Every mutation rebuilds the tree; there is no partial in-place editing, which
// keeps "what will be saved" exactly "what is on screen".
const replace = (nextTree) => onChange(nextTree)
const updateNode = (key, next) =>
replace(
tree.map((node) => {
if (keyOf(node) === key) return next
if (node.kind !== 'section') return node
return { ...node, items: node.items.map((child) => (keyOf(child) === key ? next : child)) }
}),
)
// Moving between containers is the dropdown, not a drag. The entry lands at the
// end of its destination, where it is visible and can then be dragged home.
const moveTo = (key, sectionId) => {
let moving = null
const stripped = tree
.map((node) => {
if (node.kind === 'section') {
const items = node.items.filter((child) => {
if (keyOf(child) !== key) return true
moving = child
return false
})
return { ...node, items }
}
if (keyOf(node) === key) {
moving = node
return null
}
return node
})
.filter(Boolean)
if (!moving) return
if (!sectionId) return replace([...stripped, moving])
return replace(
stripped.map((node) => (node.kind === 'section' && node.id === sectionId ? { ...node, items: [...node.items, moving] } : node)),
)
}
const addSection = () => replace([...tree, { kind: 'section', id: uid('sec'), label: 'New section', items: [] }])
// Deleting a section must NOT delete what is inside it: those are coded pages
// and the admin's own links, and losing them to a mis-click would be the one
// destructive act this screen could commit. They move back to the top level.
const deleteSection = (id) => {
const section = tree.find((n) => n.kind === 'section' && n.id === id)
if (!section) return
replace([...tree.filter((n) => keyOf(n) !== id), ...(section.items || [])])
}
const deleteLink = (id) =>
replace(
tree
.filter((n) => keyOf(n) !== id)
.map((n) => (n.kind === 'section' ? { ...n, items: n.items.filter((c) => keyOf(c) !== id) } : n)),
)
const submitLink = () => {
const error = badLinkPath(adding.to)
if (error) return setAdding({ ...adding, error })
const label = adding.label.trim()
if (!label) return setAdding({ ...adding, error: 'Give the link a name.' })
replace([...tree, { kind: 'link', id: uid('lnk'), label, to: adding.to.trim() }])
return setAdding(null)
}
const onDragEnd = (containerId) => (event) => {
const { active, over } = event
if (!over || active.id === over.id) return
if (containerId === null) {
const from = tree.findIndex((n) => keyOf(n) === active.id)
const to = tree.findIndex((n) => keyOf(n) === over.id)
if (from < 0 || to < 0) return
return replace(arrayMove(tree, from, to))
}
return replace(
tree.map((node) => {
if (node.kind !== 'section' || node.id !== containerId) return node
const from = node.items.findIndex((c) => keyOf(c) === active.id)
const to = node.items.findIndex((c) => keyOf(c) === over.id)
if (from < 0 || to < 0) return node
return { ...node, items: arrayMove(node.items, from, to) }
}),
)
}
const renderRow = (node, sectionId) => (
<Row
key={keyOf(node)}
id={keyOf(node)}
row={node}
destinations={destinations}
destination={sectionId ?? ''}
onDestination={(value) => moveTo(keyOf(node), value)}
onChange={(next) => updateNode(keyOf(node), next)}
onDelete={node.kind === 'link' ? () => deleteLink(node.id) : undefined}
/>
)
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd(null)}>
<SortableContext items={tree.map(keyOf)} strategy={verticalListSortingStrategy}>
<ul style={{ display: 'flex', flexDirection: 'column', gap: 6, margin: 0, padding: 0 }}>
{tree.map((node, index) =>
node.kind === 'section' ? (
<SectionCard
key={node.id}
section={node}
index={index}
onChange={(next) => updateNode(node.id, next)}
onDelete={() => deleteSection(node.id)}
>
{/* A nested context, so a drag inside a dropdown reorders that
dropdown rather than escaping into the header. */}
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd(node.id)}>
<SortableContext items={(node.items || []).map(keyOf)} strategy={verticalListSortingStrategy}>
<ul style={{ display: 'flex', flexDirection: 'column', gap: 6, margin: '10px 0 0', padding: '0 0 0 22px' }}>
{(node.items || []).map((child) => renderRow(child, node.id))}
{(node.items || []).length === 0 && (
<li className="sans dim" style={{ fontSize: '0.76rem', listStyle: 'none', padding: '4px 2px' }}>
Empty an empty dropdown is not shown on the site.
</li>
)}
</ul>
</SortableContext>
</DndContext>
</SectionCard>
) : (
renderRow(node, null)
),
)}
</ul>
</SortableContext>
</DndContext>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
<button type="button" className="pill" onClick={addSection}>
+ Add dropdown section
</button>
<button type="button" className="pill" onClick={() => setAdding({ label: '', to: '', error: null })}>
+ Add link
</button>
</div>
{adding && (
<Modal
title="Add a link"
onClose={() => setAdding(null)}
width={480}
footer={
<>
<button className="pill" onClick={() => setAdding(null)}>Cancel</button>
<button className="btn btn-primary btn-sq" onClick={submitLink}>Add link</button>
</>
}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<label style={{ display: 'block' }}>
<span className="field-label">Name</span>
<input
className="input"
value={adding.label}
maxLength={64}
placeholder="Player Guide"
onChange={(e) => setAdding({ ...adding, label: e.target.value, error: null })}
/>
</label>
<label style={{ display: 'block' }}>
<span className="field-label">Path on this site</span>
<input
className="input"
value={adding.to}
maxLength={128}
placeholder="/wiki/new-player-guide"
onChange={(e) => setAdding({ ...adding, to: e.target.value, error: null })}
/>
</label>
<p className="sans dim" style={{ margin: 0, fontSize: '0.76rem', lineHeight: 1.7 }}>
Links point somewhere on this site a wiki page, a custom page, any section of the site.
They are not gated: the page itself still decides who may open it, so a link to something
restricted behaves exactly as typing its address would.
</p>
{adding.error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{adding.error}</span>}
</div>
</Modal>
)}
</div>
)
}

View File

@@ -35,18 +35,6 @@ const FIELDS = [
], ],
fallback: 'disabled', fallback: 'disabled',
}, },
{
key: 'game_account_signup',
label: 'Game-account creation',
help: 'Whether players can create a GAME account (for the game client) from the site. The game servers own SignupMode (Bridge.cfg) must agree: website/hybrid accept site-created accounts, game refuses them. When enabled, a “Create a game account” form appears in the player portal.',
options: [
{ value: 'disabled', label: 'Disabled — link an existing account only' },
{ value: 'website', label: 'Website — the site creates game accounts' },
{ value: 'hybrid', label: 'Hybrid — site or in-game (recommended)' },
{ value: 'game', label: 'Game only — created in the game client, not the site' },
],
fallback: 'disabled',
},
] ]
export default function SettingsAdmin() { export default function SettingsAdmin() {

View File

@@ -1,248 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useShardFeed } from '../../../lib/useShardFeed.js'
import { describe, kindLabel } from '../../../lib/shardEvents.js'
import { ago } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
// Full live feed from the admin SSE channel — every kind, incl. staff audit,
// cheat detection and login attempts that the public channel never carries.
function AdminLiveFeed() {
const { events, connected } = useShardFeed({ url: api.adminShardStreamUrl, max: 60 })
return (
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Live feed (all events)</h3>
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)' }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
{connected ? 'Live' : 'Offline'}
</span>
</div>
{events.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>Waiting for shard events</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 360, overflowY: 'auto' }}>
{events.map((e) => (
<li key={e._id} style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: '0.85rem' }}>
<span className="sans" style={{ flex: 'none', fontSize: '0.6rem', letterSpacing: '0.06em', textTransform: 'uppercase', color: 'var(--accent)', minWidth: 92 }}>{kindLabel(e.kind)}</span>
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--ink)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{describe(e)}</span>
<span className="sans dim" style={{ flex: 'none', fontSize: '0.74rem' }}>{ago(e.t)}</span>
</li>
))}
</ul>
)}
</section>
)
}
// uo-link sidecar control panel. The auth token is write-only over this API —
// stored encrypted, never returned — same convention as the Discord bot token.
// Saving (re)starts the WS ingest client, so Enabled/URL/token changes take
// effect immediately with no redeploy.
function Toggle({ checked, onChange, label }) {
return (
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: '0.9rem', color: 'var(--ink)' }}>
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
{label}
</label>
)
}
const STATUS_COLOR = {
connected: '#7fd0a4',
reconnecting: '#e0b070',
error: '#d98b84',
disconnected: 'var(--muted)',
}
function StatusPanel({ config }) {
const color = STATUS_COLOR[config.status] || 'var(--muted)'
const ingest = config.ingest || {}
const health = config.health || {}
return (
<div style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16, display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ width: 9, height: 9, borderRadius: '50%', background: color, boxShadow: `0 0 8px ${color}` }} />
<span className="sans" style={{ fontSize: '0.9rem', color: 'var(--ink)', textTransform: 'capitalize' }}>
{config.status || 'disconnected'}
</span>
</div>
{config.statusDetail && (
<p className="sans" style={{ margin: 0, fontSize: '0.82rem', color: 'var(--muted)' }}>{config.statusDetail}</p>
)}
<div className="sans dim" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 16px', fontSize: '0.78rem', marginTop: 2 }}>
<span>Shard link: <strong style={{ color: 'var(--ink)' }}>{config.pluginConnected ? 'up' : 'down'}</strong></span>
<span>WS ingest: <strong style={{ color: 'var(--ink)' }}>{ingest.connected ? 'connected' : 'offline'}</strong></span>
<span>Reconnects: <strong style={{ color: 'var(--ink)' }}>{ingest.reconnects ?? 0}</strong></span>
<span>SSE clients: <strong style={{ color: 'var(--ink)' }}>{(config.sse?.publicClients ?? 0) + (config.sse?.adminClients ?? 0)}</strong></span>
{config.lastEventAt && <span style={{ gridColumn: '1 / -1' }}>Last event: {new Date(config.lastEventAt).toLocaleString()}</span>}
{health.uptime && <span style={{ gridColumn: '1 / -1' }}>Sidecar uptime: {health.uptime}</span>}
</div>
</div>
)
}
// ── Town crier ──────────────────────────────────────────────────────────────
function TownCrier() {
const [id, setId] = useState('')
const [text, setText] = useState('')
const [durationSec, setDurationSec] = useState(3600)
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const [error, setError] = useState('')
async function post() {
setBusy(true); setMsg(''); setError('')
const lines = text.split('\n').map((l) => l.trim()).filter(Boolean)
if (!id.trim() || lines.length === 0) {
setBusy(false)
return setError('An id and at least one line are required.')
}
try {
await api.admin.postTownCrier({ id: id.trim(), lines, durationSec: Number(durationSec) || undefined })
setMsg(`Posted “${id.trim()}”.`)
} catch (err) {
setError(err.message || 'Could not post.')
} finally {
setBusy(false)
}
}
async function remove() {
if (!id.trim()) return setError('Enter the id to remove.')
setBusy(true); setMsg(''); setError('')
try {
await api.admin.deleteTownCrier(id.trim())
setMsg(`Removed “${id.trim()}”.`)
} catch (err) {
setError(err.message || 'Could not remove.')
} finally {
setBusy(false)
}
}
return (
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22, display: 'flex', flexDirection: 'column', gap: 12 }}>
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Town crier</h3>
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem', lineHeight: 1.6 }}>
Broadcast a message that every in-game town crier announces until it expires. Re-posting the same id replaces it.
</p>
<label style={{ display: 'block' }}>
<span className="field-label">Message id</span>
<input type="text" value={id} onChange={(e) => setId(e.target.value)} className="input" placeholder="news-42" autoComplete="off" style={{ maxWidth: 220 }} />
</label>
<label style={{ display: 'block' }}>
<span className="field-label">Lines (one per line)</span>
<textarea value={text} onChange={(e) => setText(e.target.value)} className="input" rows={3} placeholder={'Hear ye!\nMarket tax is now 5%.'} style={{ resize: 'vertical' }} />
</label>
<label style={{ display: 'block' }}>
<span className="field-label">Duration (seconds)</span>
<input type="number" value={durationSec} onChange={(e) => setDurationSec(e.target.value)} className="input" min={1} max={86400} style={{ maxWidth: 160 }} />
</label>
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<button onClick={post} disabled={busy} className="btn btn-primary btn-sq">{busy ? 'Working…' : 'Post message'}</button>
<button onClick={remove} disabled={busy} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>Remove by id</button>
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
</div>
</section>
)
}
export default function ShardAdmin() {
const [config, setConfig] = useState(null)
const [error, setError] = useState('')
const [baseUrl, setBaseUrl] = useState('')
const [wsUrl, setWsUrl] = useState('')
const [token, setToken] = useState('')
const [protocol, setProtocol] = useState(1)
const [enabled, setEnabled] = useState(false)
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const [saveError, setSaveError] = useState('')
const pollRef = useRef(null)
const initializedRef = useRef(false)
const load = useCallback(async () => {
try {
const c = await api.admin.getUoLinkConfig()
setConfig(c)
// Seed the editable fields once; later polls only refresh the status panel
// so they never clobber what the admin is mid-typing.
if (!initializedRef.current) {
setBaseUrl(c.baseUrl || '')
setWsUrl(c.wsUrl || '')
setProtocol(c.protocol || 1)
setEnabled(c.enabled)
initializedRef.current = true
}
} catch {
setError('Could not load uo-link config.')
}
}, [])
useEffect(() => {
load()
pollRef.current = setInterval(load, 5000)
return () => clearInterval(pollRef.current)
}, [load])
async function save() {
setBusy(true); setMsg(''); setSaveError('')
try {
const body = { baseUrl, wsUrl, protocol: Number(protocol), enabled }
if (token) body.token = token
const saved = await api.admin.saveUoLinkConfig(body)
setConfig(saved)
setToken('')
setMsg('Saved.')
} catch (err) {
setSaveError(err.message || 'Could not save.')
} finally {
setBusy(false)
}
}
if (error) return <ErrorState message={error} />
if (!config) return <Loading />
return (
<section style={{ maxWidth: 560, display: 'flex', flexDirection: 'column', gap: 20 }}>
<h2 className="display" style={{ margin: 0, fontSize: '1.2rem', color: 'var(--head)' }}>Shard (uo-link)</h2>
<StatusPanel config={config} />
<Toggle checked={enabled} onChange={setEnabled} label="Enable the shard integration" />
<label style={{ display: 'block' }}>
<span className="field-label">Base URL (REST)</span>
<input type="text" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} className="input" autoComplete="off" placeholder="http://127.0.0.1:8080" />
</label>
<label style={{ display: 'block' }}>
<span className="field-label">WebSocket URL (feed)</span>
<input type="text" value={wsUrl} onChange={(e) => setWsUrl(e.target.value)} className="input" autoComplete="off" placeholder="ws://127.0.0.1:8080/ws" />
</label>
<label style={{ display: 'block' }}>
<span className="field-label">Auth token</span>
<input type="password" value={token} onChange={(e) => setToken(e.target.value)} className="input" autoComplete="new-password" placeholder={config.hasToken ? '•••••••• configured — leave blank to keep' : 'Shared secret from sidecar.toml'} />
</label>
<label style={{ display: 'block', maxWidth: 140 }}>
<span className="field-label">Protocol</span>
<input type="number" value={protocol} onChange={(e) => setProtocol(e.target.value)} className="input" min={1} max={99} />
</label>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginTop: 4 }}>
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">{busy ? 'Saving…' : 'Save changes'}</button>
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
{saveError && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{saveError}</span>}
</div>
<TownCrier />
<AdminLiveFeed />
</section>
)
}

View File

@@ -1,291 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useShardFeed } from '../../../lib/useShardFeed.js'
import { describe } from '../../../lib/shardEvents.js'
import { ago } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
// In-game staff operations: the uo-link write plane (broadcast / kick / ban /
// unban) and the help-page support queue, plus a live audit log. Open to admins
// and moderators. The acting staff member (`actor`) is attached server-side from
// the session — nothing here sends it — so every action is attributable.
function Flash({ ok, err }) {
if (ok) return <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{ok}</span>
if (err) return <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{err}</span>
return null
}
// ── Broadcast ────────────────────────────────────────────────────────────────
function Broadcast() {
const [text, setText] = useState('')
const [hue, setHue] = useState('')
const [busy, setBusy] = useState(false)
const [ok, setOk] = useState('')
const [err, setErr] = useState('')
async function send() {
if (!text.trim()) return setErr('Enter a message.')
setBusy(true); setOk(''); setErr('')
try {
await api.admin.shardOps.broadcast({ text: text.trim(), hue: hue === '' ? undefined : Number(hue) })
setOk('Broadcast sent.')
setText('')
} catch (e) {
setErr(e.message || 'Could not broadcast.')
} finally {
setBusy(false)
}
}
return (
<section style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Broadcast</h3>
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem' }}>
A system message shown to everyone online right now.
</p>
<label style={{ display: 'block' }}>
<span className="field-label">Message</span>
<input type="text" value={text} onChange={(e) => setText(e.target.value)} className="input" maxLength={300} placeholder="Server restart in 5 minutes" autoComplete="off" />
</label>
<label style={{ display: 'block', maxWidth: 140 }}>
<span className="field-label">Hue (optional)</span>
<input type="number" value={hue} onChange={(e) => setHue(e.target.value)} className="input" min={0} max={3000} placeholder="53" />
</label>
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<button onClick={send} disabled={busy} className="btn btn-primary btn-sq">{busy ? 'Sending…' : 'Broadcast'}</button>
<Flash ok={ok} err={err} />
</div>
</section>
)
}
// ── Account actions (kick / ban / unban) ─────────────────────────────────────
function AccountActions() {
const [account, setAccount] = useState('')
const [durationSec, setDurationSec] = useState('')
const [reason, setReason] = useState('')
const [busy, setBusy] = useState('')
const [ok, setOk] = useState('')
const [err, setErr] = useState('')
const acct = account.trim()
function guard() {
if (!acct) {
setErr('Enter an account name.')
return false
}
return true
}
async function run(label, fn, done) {
if (!guard()) return
setBusy(label); setOk(''); setErr('')
try {
const r = await fn()
setOk(done(r))
} catch (e) {
setErr(e.message || 'Action failed.')
} finally {
setBusy('')
}
}
const kick = () =>
run('kick', () => api.admin.shardOps.kick({ account: acct }), (r) => {
const n = r?.sessions != null ? r.sessions : null
const plural = n === 1 ? '' : 's'
const sessions = n != null ? ` (${n} session${plural})` : ''
return `Kicked ${acct}${sessions}.`
})
const ban = () =>
run(
'ban',
() =>
api.admin.shardOps.ban({
account: acct,
durationSec: durationSec === '' ? undefined : Number(durationSec),
reason: reason.trim() || undefined,
}),
() => {
const when = durationSec ? ` for ${durationSec}s` : ' indefinitely'
return `Banned ${acct}${when}.`
},
)
const unban = () => run('unban', () => api.admin.shardOps.unban(acct), () => `Unbanned ${acct}.`)
return (
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22, display: 'flex', flexDirection: 'column', gap: 12 }}>
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Account actions</h3>
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem' }}>
Kick, ban or unban a game account. Bans work even if the account is offline; the shard refuses to act on staff at or above co-owner.
</p>
<label style={{ display: 'block' }}>
<span className="field-label">Account</span>
<input type="text" value={account} onChange={(e) => setAccount(e.target.value)} className="input" placeholder="griefer42" autoComplete="off" style={{ maxWidth: 260 }} />
</label>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<label style={{ display: 'block', maxWidth: 200 }}>
<span className="field-label">Ban duration (seconds, blank = permanent)</span>
<input type="number" value={durationSec} onChange={(e) => setDurationSec(e.target.value)} className="input" min={0} placeholder="604800" />
</label>
<label style={{ display: 'block', flex: 1, minWidth: 200 }}>
<span className="field-label">Ban reason (optional)</span>
<input type="text" value={reason} onChange={(e) => setReason(e.target.value)} className="input" maxLength={500} placeholder="harassment" autoComplete="off" />
</label>
</div>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={kick} disabled={!!busy} className="btn btn-sq">{busy === 'kick' ? 'Kicking…' : 'Kick'}</button>
<button onClick={ban} disabled={!!busy} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>{busy === 'ban' ? 'Banning…' : 'Ban'}</button>
<button onClick={unban} disabled={!!busy} className="btn btn-sq">{busy === 'unban' ? 'Unbanning…' : 'Unban'}</button>
<Flash ok={ok} err={err} />
</div>
</section>
)
}
// ── Support (help-page) queue ────────────────────────────────────────────────
function PageRow({ page, onDone }) {
const [message, setMessage] = useState('')
const [busy, setBusy] = useState('')
const [err, setErr] = useState('')
async function respond(close) {
if (!message.trim()) return setErr('Enter a reply first.')
setBusy(close ? 'respond-close' : 'respond'); setErr('')
try {
await api.admin.shardOps.respondPage(page.pageId, { message: message.trim(), close })
onDone()
} catch (e) {
setErr(e.message || 'Could not send.')
setBusy('')
}
}
async function close() {
setBusy('close'); setErr('')
try {
await api.admin.shardOps.closePage(page.pageId)
onDone()
} catch (e) {
setErr(e.message || 'Could not close.')
setBusy('')
}
}
return (
<div className="panel" style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
<div style={{ minWidth: 0 }}>
<span className="sans" style={{ fontSize: '0.62rem', letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--accent)' }}>{page.type || 'Page'}</span>
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.95rem' }}>
{page.sender?.name || page.pageId}
{page.handled && <span className="dim" style={{ fontSize: '0.72rem' }}> · claimed{page.handler ? ` by ${page.handler}` : ''}</span>}
</div>
</div>
<span className="sans dim" style={{ flex: 'none', fontSize: '0.74rem' }}>{page.sentMs ? ago(page.sentMs) : ''}</span>
</div>
{page.message && <p className="sans" style={{ margin: 0, color: 'var(--ink)', fontSize: '0.88rem', lineHeight: 1.5 }}>{page.message}</p>}
<div className="sans dim" style={{ fontSize: '0.72rem' }}>
{page.map || '—'}{page.x != null ? ` (${page.x}, ${page.y})` : ''}
</div>
<textarea value={message} onChange={(e) => setMessage(e.target.value)} className="input" rows={2} placeholder="A GM is on the way." style={{ resize: 'vertical' }} />
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={() => respond(false)} disabled={!!busy} className="btn btn-sq">{busy === 'respond' ? 'Sending…' : 'Reply'}</button>
<button onClick={() => respond(true)} disabled={!!busy} className="btn btn-primary btn-sq">{busy === 'respond-close' ? 'Sending…' : 'Reply & close'}</button>
<button onClick={close} disabled={!!busy} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>{busy === 'close' ? 'Closing…' : 'Close'}</button>
{err && <span className="sans" style={{ color: '#d98b84', fontSize: '0.8rem' }}>{err}</span>}
</div>
</div>
)
}
function SupportQueue() {
const [pages, setPages] = useState(null)
const [err, setErr] = useState('')
const pollRef = useRef(null)
const load = useCallback(async () => {
try {
setPages(await api.admin.shardOps.pages())
} catch {
setErr('Could not load the support queue.')
}
}, [])
useEffect(() => {
load()
pollRef.current = setInterval(load, 7000)
return () => clearInterval(pollRef.current)
}, [load])
let queueBody
if (pages == null) {
queueBody = <p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>Loading</p>
} else if (pages.length === 0) {
queueBody = <p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>The queue is empty.</p>
} else {
queueBody = (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{pages.map((p) => <PageRow key={p.pageId} page={p} onDone={load} />)}
</div>
)
}
return (
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22, display: 'flex', flexDirection: 'column', gap: 12 }}>
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Support queue</h3>
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem' }}>
Open help pages from players. A reply reaches them in game (or on their next login).
</p>
{err && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{err}</span>}
{queueBody}
</section>
)
}
// ── Audit log ────────────────────────────────────────────────────────────────
// Seeded from the stored admin.audit history, then kept live from the admin SSE
// channel (which carries every kind — we filter to admin.audit here).
function AuditLog() {
const [seed, setSeed] = useState([])
const { events } = useShardFeed({ url: api.adminShardStreamUrl, filter: new Set(['admin.audit']), max: 50 })
useEffect(() => {
api.admin.shardOps
.audit(50)
.then((rows) => setSeed(rows.map((r) => ({ ...r, _id: `seed-${r.id}` }))))
.catch(() => setSeed([]))
}, [])
// Live events on top; fall back to the seed for anything older than the live tail.
const oldestLive = events.length ? Math.min(...events.map((e) => e.t || 0)) : Infinity
const rows = [...events, ...seed.filter((s) => (s.t || 0) < oldestLive)].slice(0, 60)
return (
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22 }}>
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)', marginBottom: 12 }}>Audit log</h3>
{rows.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No moderation actions recorded yet.</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 320, overflowY: 'auto' }}>
{rows.map((e) => (
<li key={e._id} style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: '0.85rem' }}>
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--ink)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{describe(e)}</span>
<span className="sans dim" style={{ flex: 'none', fontSize: '0.74rem' }}>{ago(e.t)}</span>
</li>
))}
</ul>
)}
</section>
)
}
export default function ShardOps() {
return (
<section style={{ maxWidth: 620, display: 'flex', flexDirection: 'column', gap: 22 }}>
<Broadcast />
<AccountActions />
<SupportQueue />
<AuditLog />
</section>
)
}

View File

@@ -1,17 +1,16 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { useParams, Link } from 'react-router-dom' import { useParams, Link } from 'react-router-dom'
import { Loading, ErrorState } from '../../../components/PageState.jsx' import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useAsync } from '../../../lib/useAsync.js' import { useAsync } from '../../../lib/useAsync.js'
import { dateTime, ago } from '../../../lib/format.js' import { dateTime } from '../../../lib/format.js'
import { api } from '../../../api/client.js' import { api } from '../../../api/client.js'
import CharacterStats from '../../../components/CharacterStats.jsx' import Slot from '../../../modules/Slot.jsx'
import GameAccounts from '../../../components/GameAccounts.jsx'
import VendorSales from '../../../components/VendorSales.jsx'
// Admin read-only view of one user's shard (uo-link) footprint: linked game // Admin view of one user: who they are, their security posture (trusted devices
// accounts + character rosters, currently-online characters, houses (incl. // and MFA), and then whatever the installed module contributes about them —
// IDOC) and recent vendor sales — everything scoped to that user's accounts. // today core's own UO footprint, via the `admin.users.detail` extension slot
// Reached from the Users table's "View" action; Edit stays a separate modal. // (MODULE_API.md §3.7). Reached from the Users table's "View" action; Edit stays
// a separate modal.
const ROLE_BADGE = { const ROLE_BADGE = {
admin: 'badge-admin', admin: 'badge-admin',
@@ -28,114 +27,6 @@ function SectionTitle({ children }) {
) )
} }
// Currently-online characters on the user's accounts, with where they are. The
// per-character Online/Offline badge lives in the roster; this adds location.
function OnlineNow({ scope }) {
const { data } = useAsync(() => scope.online(), [scope])
if (!data) return null
return (
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
<SectionTitle>Online now</SectionTitle>
{data.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No characters online right now.</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
{data.map((c) => (
<li key={c.serial} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#7fd0a4', boxShadow: '0 0 6px #7fd0a4', flex: 'none' }} />
<span style={{ color: 'var(--head)' }}>{c.name || '(unnamed)'}</span>
</span>
<span className="dim" style={{ flex: 'none', fontSize: '0.8rem' }}>
{c.map != null ? `map ${c.map} · ${c.x}, ${c.y}` : '—'}
</span>
</li>
))}
</ul>
)}
</section>
)
}
// Shard "standing": city governorships held and guilds led by this user's
// accounts (both reliable current-state lookups). Renders nothing when empty.
function Standing({ scope }) {
const { data } = useAsync(() => scope.standing(), [scope])
if (!data) return null
const govs = data.governorOf || []
const guilds = data.guildsLed || []
if (govs.length === 0 && guilds.length === 0) return null
return (
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
<SectionTitle>Standing</SectionTitle>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{govs.map((g) => (
<span key={`gov-${g.city}`} className="sans" style={{ fontSize: '0.78rem', padding: '4px 10px', borderRadius: 999, border: '1px solid #c9a24b55', color: '#c9a24b' }}>
Governor of {g.city}
</span>
))}
{guilds.map((g) => (
<span key={`guild-${g.id}`} className="sans" style={{ fontSize: '0.78rem', padding: '4px 10px', borderRadius: 999, border: '1px solid var(--accent)', color: 'var(--accent)' }}>
Guildmaster{g.abbr ? `, [${g.abbr}]` : ''} {g.name}
</span>
))}
</div>
</section>
)
}
// One house row — the many optional detail fields are gathered here so the
// Houses list stays a simple map.
function HouseRow({ house: h }) {
const location = h.region || (h.map != null ? `map ${h.map}` : 'unknown')
const coords = h.x != null ? ` · ${h.x}, ${h.y}` : ''
const owner = h.ownerAcct ? ` · ${h.ownerAcct}` : ''
const shares = h.coOwners || h.friends ? ` · ${h.coOwners || 0} co-owners, ${h.friends || 0} friends` : ''
return (
<li
style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'baseline', padding: '12px 14px', border: '1px solid var(--line)', borderRadius: 10, background: 'rgba(255,255,255,0.02)' }}
>
<div style={{ minWidth: 0 }}>
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.95rem' }}>
{h.name || 'Unnamed house'}
{h.isIdoc && <span className="badge" style={{ marginLeft: 8, background: '#5b2020', color: '#f0c8c2' }}>IDOC</span>}
</div>
<div className="sans dim" style={{ fontSize: '0.78rem', marginTop: 2 }}>
{location}
{coords}
{owner}
{shares}
</div>
</div>
<div className="sans dim" style={{ flex: 'none', fontSize: '0.78rem', textAlign: 'right' }}>
{(h.decay || h.stage) ? <div style={{ color: h.isIdoc ? '#e0928a' : 'var(--muted)' }}>{h.decay || h.stage}</div> : null}
{h.price != null ? <div style={{ fontVariantNumeric: 'tabular-nums' }}>{Number(h.price).toLocaleString()} gp</div> : null}
{h.lastRefreshed ? <div>refreshed {ago(h.lastRefreshed)}</div> : null}
</div>
</li>
)
}
// Houses owned by the user's accounts, IDOC first (flagged).
function Houses({ scope }) {
const { data } = useAsync(() => scope.houses(), [scope])
if (!data) return null
return (
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
<SectionTitle>Houses</SectionTitle>
{data.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No houses recorded for this users accounts.</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
{data.map((h) => (
<HouseRow key={h.serial} house={h} />
))}
</ul>
)}
</section>
)
}
// Admin security controls for one user: their trusted devices (view + revoke) and // Admin security controls for one user: their trusted devices (view + revoke) and
// an MFA reset for a locked-out user. Every action is audit-logged server-side. // an MFA reset for a locked-out user. Every action is audit-logged server-side.
function SecurityAdmin({ userId }) { function SecurityAdmin({ userId }) {
@@ -244,25 +135,8 @@ function SecurityAdmin({ userId }) {
) )
} }
function ShardSections({ scope }) {
return (
<>
<CharacterStats scope={scope} />
<SectionTitle>Linked accounts &amp; characters</SectionTitle>
<GameAccounts scope={scope} readOnly moderation onUnlink={scope.unlink} charTo={(serial) => `/admin/characters/${serial}`} />
<Standing scope={scope} />
<OnlineNow scope={scope} />
<Houses scope={scope} />
<VendorSales fetchSales={scope.sales} />
</>
)
}
export default function UserDetail() { export default function UserDetail() {
const { id } = useParams() const { id } = useParams()
// Memoize so the child components' effects (keyed on `scope`) don't refetch
// on every render.
const scope = useMemo(() => api.admin.userShard(id), [id])
const { loading, error, data: user } = useAsync(() => api.admin.getUser(id), [id]) const { loading, error, data: user } = useAsync(() => api.admin.getUser(id), [id])
if (loading) return <Loading /> if (loading) return <Loading />
@@ -296,7 +170,11 @@ export default function UserDetail() {
</div> </div>
<SecurityAdmin userId={id} /> <SecurityAdmin userId={id} />
<ShardSections scope={scope} /> {/* Whatever the installed module has to say about this user, or nothing
at all — core filled this with its own UO sections until Phase 3 slice
3, and now nothing does unless a module is installed
(MODULE_API.md §3.7). */}
<Slot name="admin.users.detail" userId={id} />
</section> </section>
) )
} }

View File

@@ -1,14 +1,22 @@
import { useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom' import { Link, useNavigate, useParams } from 'react-router-dom'
import { useAuth } from '../../contexts/AuthContext.jsx' import { useAuth } from '../../contexts/AuthContext.jsx'
import { api } from '../../api/client.js' import { api } from '../../api/client.js'
import PlayerShell, { honeypotStyle } from './PlayerShell.jsx' import PlayerShell, { honeypotStyle } from './PlayerShell.jsx'
import CreateGameAccountForm from '../../components/CreateGameAccountForm.jsx' import Slot from '../../modules/Slot.jsx'
import { extensionFor } from '../../modules/registry.js'
// Public, token-gated invite acceptance (/invite/:token). Validates the invite, // Public, token-gated invite acceptance (/invite/:token). Validates the invite,
// lets the invitee set a username + password (their email + role are pre-assigned), // lets the invitee set a username + password (their email + role are pre-assigned),
// creates the account at that role and logs them in. For a player invite it then // creates the account at that role and logs them in.
// offers the built-in "create game account" step before sending them to the portal. //
// For a PLAYER invite there may then be one more step, supplied by an installed
// module through the `player.invite.accepted` slot: core rendered a UO
// game-account form here itself until Phase 3 slice 3, reading a
// `gameAccountSignup` flag out of its own settings and posting to a shard route.
// Neither of those is core's. What core keeps is the shell, the skip control and
// the destination; whether there is a step at all is the module's call, made
// from data core does not have.
export default function AcceptInvite() { export default function AcceptInvite() {
const { token } = useParams() const { token } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
@@ -16,7 +24,6 @@ export default function AcceptInvite() {
const [invite, setInvite] = useState(null) // fields email and role const [invite, setInvite] = useState(null) // fields email and role
const [loadErr, setLoadErr] = useState('') const [loadErr, setLoadErr] = useState('')
const [signupOk, setSignupOk] = useState(false)
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
@@ -30,14 +37,20 @@ export default function AcceptInvite() {
api.getInvite(token) api.getInvite(token)
.then((iv) => active && setInvite(iv)) .then((iv) => active && setInvite(iv))
.catch((err) => active && setLoadErr(err.status === 404 ? 'This invitation is invalid or has expired.' : 'Could not load this invitation.')) .catch((err) => active && setLoadErr(err.status === 404 ? 'This invitation is invalid or has expired.' : 'Could not load this invitation.'))
api.publicSettings()
.then((s) => active && setSignupOk(Boolean(s?.gameAccountSignup)))
.catch(() => {})
return () => { active = false } return () => { active = false }
}, [token]) }, [token])
const dest = invite && invite.role === 'player' ? '/player' : '/admin' const dest = invite && invite.role === 'player' ? '/player' : '/admin'
// Whether anything is installed that wants the post-acceptance step. Read
// rather than rendered blind because it decides a NAVIGATION, not just what
// appears: with nothing filled there is no screen to show, so the invitee goes
// straight to their destination. This is the one legitimate reason to ask
// whether a slot is filled — the answer changes control flow, not decoration
// (decoration goes inside `<Slot wrap>`, which is why `hasExtension` is gone).
const hasNextStep = Boolean(extensionFor('player.invite.accepted'))
const finish = useCallback(() => navigate('/player', { replace: true }), [navigate])
async function onSubmit(e) { async function onSubmit(e) {
e.preventDefault() e.preventDefault()
setError('') setError('')
@@ -48,8 +61,9 @@ export default function AcceptInvite() {
await api.acceptInvite(token, username.trim(), password, { company }) await api.acceptInvite(token, username.trim(), password, { company })
await refresh() // pull the freshly-issued session into context await refresh() // pull the freshly-issued session into context
setAccepted(true) setAccepted(true)
// Staff invites are web-only — no game step; go straight in. // Staff invites go straight in, and so does a player invite when nothing
if (!(invite.role === 'player' && signupOk)) navigate(dest, { replace: true }) // is installed that has a step to offer.
if (!(invite.role === 'player' && hasNextStep)) navigate(dest, { replace: true })
} catch (err) { } catch (err) {
if (err.status === 409) setError('That username is already taken, or the invite was already used.') if (err.status === 409) setError('That username is already taken, or the invite was already used.')
else if (err.status === 404) setError('This invitation is invalid or has expired.') else if (err.status === 404) setError('This invitation is invalid or has expired.')
@@ -78,19 +92,22 @@ export default function AcceptInvite() {
) )
} }
// ── Accepted: optional game-account step (player invites) ────────────────── // ── Accepted: a module's optional next step (player invites) ───────────────
//
// Only reachable when the slot is filled — `onSubmit` navigates away otherwise
// — so there is no empty-shell case to guard here.
//
// The subtitle is core's and says nothing about what the step is: naming it
// would be core describing content it does not own, and the wrong description
// is worse than a general one. "Skip" stays core's too, because where it goes
// is core's decision, and it is rendered outside the slot deliberately — an
// extension that throws must not take the way out with it.
if (accepted) { if (accepted) {
return ( return (
<PlayerShell subtitle="Set up your game account"> <PlayerShell subtitle="One more step">
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}> <Slot name="player.invite.accepted" onDone={finish} />
Your account is ready. Create a game account now to play, or skip and do it later from your portal.
</p>
<CreateGameAccountForm
submit={api.player.shard.createAccount}
onCreated={() => navigate('/player', { replace: true })}
/>
<p className="sans" style={{ textAlign: 'center', margin: '18px 0 0' }}> <p className="sans" style={{ textAlign: 'center', margin: '18px 0 0' }}>
<button type="button" onClick={() => navigate('/player', { replace: true })} className="btn" style={{ background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer' }}> <button type="button" onClick={finish} className="btn" style={{ background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer' }}>
Skip for now Skip for now
</button> </button>
</p> </p>

View File

@@ -1,29 +0,0 @@
import { useParams, Link } from 'react-router-dom'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import CharacterSheet from '../../components/CharacterSheet.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { api } from '../../api/client.js'
// A player's character sheet inside the portal. Owner-checked: the endpoint only
// returns a sheet for a character on an account linked to the caller.
export default function PlayerCharacter() {
const { serial } = useParams()
const { loading, error, data } = useAsync(() => api.player.shard.char(serial), [serial])
const restarting = error && error.status === 503
const forbidden = error && error.status === 403
return (
<div>
<p style={{ margin: '0 0 18px' }}>
<Link to="/player" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.86rem' }}>
Back to characters
</Link>
</p>
{loading && <Loading />}
{restarting && <ErrorState message="The game server is restarting — try again shortly." />}
{forbidden && <ErrorState message="That character is not on an account linked to you." />}
{error && !restarting && !forbidden && <ErrorState message="Could not load that character right now." />}
{!loading && !error && data && <CharacterSheet char={data} />}
</div>
)
}

View File

@@ -1,58 +0,0 @@
import GameAccounts from '../../components/GameAccounts.jsx'
import VendorSales from '../../components/VendorSales.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { api } from '../../api/client.js'
// The logged-in player's characters. Shows the link prompt when no game account
// is linked, otherwise their characters grouped by account (shared component),
// plus their own home status and recent vendor sales.
const DECAY_TONE = {
LikeNew: '#7fd0a4', Ageless: '#7fd0a4', Slightly: '#a9cf8a', Somewhat: '#d7c56a',
Fairly: '#e0a95f', Greatly: '#d9736f', IDOC: '#e05a5a', Collapsed: '#8c96a5',
}
// The caller's own houses (home status). Only their own — never anyone else's.
function MyHouses() {
const { data } = useAsync(() => api.player.shard.houses(), [])
if (!data || data.length === 0) return null
return (
<section style={{ marginTop: 30 }}>
<div className="field-label" style={{ marginBottom: 12 }}>My houses</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{data.map((h) => {
const label = h.isIdoc ? 'IDOC' : (h.decay || h.stage)
const tone = h.isIdoc ? '#e05a5a' : (DECAY_TONE[label] || 'var(--muted)')
return (
<div key={h.serial} className="panel" style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}>
<div style={{ minWidth: 0, flex: 1 }}>
<div className="display" style={{ fontSize: '1rem', color: 'var(--head)' }}>{h.name || 'An unnamed house'}</div>
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
{h.region || h.map || '—'}{h.x != null ? ` · ${h.x}, ${h.y}` : ''}
</div>
</div>
{label && (
<span className="sans" style={{ flex: 'none', fontSize: '0.68rem', color: tone, border: `1px solid ${tone}66`, borderRadius: 999, padding: '2px 9px' }}>
{label}
</span>
)}
</div>
)
})}
</div>
<p className="sans dim" style={{ margin: '10px 0 0', fontSize: '0.76rem' }}>
Keep an eye on the decay status refresh a house in game before it reaches IDOC.
</p>
</section>
)
}
export default function PlayerCharacters() {
return (
<div>
<GameAccounts scope={api.player.shard} charTo={(serial) => `/player/char/${serial}`} />
<MyHouses />
<VendorSales fetchSales={api.player.shard.sales} />
</div>
)
}

View File

@@ -108,15 +108,28 @@ export default function PlayerLogin() {
setBusy(true) setBusy(true)
try { try {
if (ssoTotp) { if (ssoTotp) {
const { returnTo, redirect } = await ssoLoginTotp(code) // Trust works on the SSO second factor too. On the mobile bridge this page
// is running inside the app's Custom Tab, so the cookie set here is what
// lets the next app sign-in skip the code.
const data = await ssoLoginTotp(code.trim(), { trustDevice })
// Native SSO bridge (M9): a mobile 2FA completion returns an absolute // Native SSO bridge (M9): a mobile 2FA completion returns an absolute
// deep link (e.g. runicgateway://…) to hand the app its one-time code. // deep link (e.g. runicgateway://…) to hand the app its one-time code.
// React Router can't navigate a custom scheme, so leave the SPA for it. // React Router can't navigate a custom scheme, so leave the SPA for it.
if (redirect) { // This wins over the trust-cap prompt: the sign-in itself succeeded and the
window.location.href = redirect // deep link is single-use, so stalling here to manage devices would strand
// the app. An over-cap user simply isn't trusted and can prune the list
// from Account → Trusted Devices.
if (data.redirect) {
window.location.href = data.redirect
return return
} }
navigate(returnTo || '/account', { replace: true }) const to = data.returnTo || '/account'
if (data.trustLimitReached) {
setTrustLimit({ devices: data.devices || [], dest: to })
setBusy(false)
return
}
navigate(to, { replace: true })
} else { } else {
const entered = code.trim() const entered = code.trim()
const data = await loginTotp(challenge, useRecovery ? '' : entered, { const data = await loginTotp(challenge, useRecovery ? '' : entered, {
@@ -206,14 +219,15 @@ export default function PlayerLogin() {
{useRecovery ? 'Enter one of your saved single-use recovery codes.' : 'Enter the code from your authenticator app.'} {useRecovery ? 'Enter one of your saved single-use recovery codes.' : 'Enter the code from your authenticator app.'}
</span> </span>
</label> </label>
{/* Trust-this-device only applies to real authenticator/recovery login, {/* Offered on the SSO second factor too — the trust is on the device,
not the SSO 2FA bounce (which has no trust cookie flow here). */} not on how the first factor was proved. Inside the app's Custom Tab
{!ssoTotp && ( this is also what trusts the device for future native sign-ins. */}
<label className="sans" style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, color: 'var(--muted)', fontSize: '0.84rem' }}> <label className="sans" style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, color: 'var(--muted)', fontSize: '0.84rem' }}>
<input type="checkbox" checked={trustDevice} onChange={(e) => setTrustDevice(e.target.checked)} /> <input type="checkbox" checked={trustDevice} onChange={(e) => setTrustDevice(e.target.checked)} />
Trust this device for 30 days (skip the code next time) Trust this device for 30 days (skip the code next time)
</label> </label>
)} {/* Recovery codes remain password-login only: the SSO second step
verifies an authenticator code against the staged challenge. */}
{!ssoTotp && ( {!ssoTotp && (
<button <button
type="button" type="button"

View File

@@ -1,7 +1,14 @@
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom' import { useMemo } from 'react'
import { NavLink, Navigate, Outlet, useNavigate, useLocation } from 'react-router-dom'
import MoonDot from '../../components/MoonDot.jsx' import MoonDot from '../../components/MoonDot.jsx'
import BrandLogo from '../../components/BrandLogo.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx' import { useAuth } from '../../contexts/AuthContext.jsx'
import { useSite } from '../../contexts/SiteContext.jsx' import { useSite } from '../../contexts/SiteContext.jsx'
import { applyNavOverrides } from '../../lib/navOverrides.js'
import { firstDestinationFor } from '../../lib/adminNav.js'
import { useNavOverrides } from '../../lib/useNavOverrides.js'
import { withModuleNav } from '../../modules/nav.js'
import { useFeatureGate } from '../../modules/features.jsx'
// Shared shell for the logged-in player portal. Uses the same sidebar shell as // Shared shell for the logged-in player portal. Uses the same sidebar shell as
// Admin (icon nav, sticky content header, footer sign-out) so the two logged-in // Admin (icon nav, sticky content header, footer sign-out) so the two logged-in
@@ -26,24 +33,37 @@ function Icon({ children, size = 16 }) {
</svg> </svg>
) )
} }
const IconUser = () => <Icon><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></Icon>
const IconGear = () => <Icon><circle cx="12" cy="12" r="3" /><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M19.1 4.9L17 7M7 17l-2.1 2.1" /></Icon> const IconGear = () => <Icon><circle cx="12" cy="12" r="3" /><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M19.1 4.9L17 7M7 17l-2.1 2.1" /></Icon>
const IconShield = () => <Icon><path d="M12 3l7 3v5c0 5-3.5 8-7 10-3.5-2-7-5-7-10V6z" /><path d="M9 12l2 2 4-4" /></Icon> const IconShield = () => <Icon><path d="M12 3l7 3v5c0 5-3.5 8-7 10-3.5-2-7-5-7-10V6z" /><path d="M9 12l2 2 4-4" /></Icon>
const NAV = [ // Exported because Admin -> Navigation edits this list. It stays declared here;
{ to: '/player', label: 'Characters', end: true, icon: IconUser }, // the editor may only relabel, reorder and hide what it finds (§7). No CORE row
// carries a gate — every player sees both — but an installed module's rows join
// this list before the merge and may carry a `feature`, so the filter after it
// is not dead code.
//
// "Characters" was the first row and left with the client half in slice 3; the
// UO module registers it again at `/player/uo/characters`, in this position,
// with `order: 0`.
export const NAV = [
{ to: '/account/appeals', label: 'Appeals', icon: IconShield }, { to: '/account/appeals', label: 'Appeals', icon: IconShield },
{ to: '/account', label: 'Account', end: true, icon: IconGear }, { to: '/account', label: 'Account', end: true, icon: IconGear },
] ]
// The sticky content header mirrors the active page. Character sheets live under // The sticky content header mirrors the active page. A module's pages are not
// /player/char/:serial and keep their own in-page back link. // here and cannot be — core does not know what they are called — so they title
// from their own nav row, the same rule AdminLayout's `moduleTitle` follows.
const TITLES = { const TITLES = {
'/player': 'Characters',
'/account': 'Account', '/account': 'Account',
'/account/appeals': 'Appeals', '/account/appeals': 'Appeals',
} }
function moduleTitle(baseNav, pathname) {
return baseNav
.filter((i) => i.moduleId && (pathname === i.to || pathname.startsWith(`${i.to}/`)))
.sort((a, b) => b.to.length - a.to.length)[0]?.label
}
const navBtnBase = { const navBtnBase = {
textAlign: 'left', textAlign: 'left',
borderRadius: 8, borderRadius: 8,
@@ -60,11 +80,16 @@ const navBtnBase = {
export default function PlayerPortalLayout() { export default function PlayerPortalLayout() {
const { user, logout } = useAuth() const { user, logout } = useAuth()
const { siteTitle } = useSite() const { siteTitle } = useSite()
const navOverrides = useNavOverrides()
const isVisible = useFeatureGate()
const baseNav = useMemo(() => withModuleNav(NAV, 'player'), [])
const nav = useMemo(
() => applyNavOverrides(baseNav, navOverrides.nav_player).filter(isVisible),
[baseNav, navOverrides.nav_player, isVisible],
)
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const title = const title = TITLES[location.pathname] || moduleTitle(baseNav, location.pathname) || 'Player Portal'
TITLES[location.pathname] ||
(location.pathname.startsWith('/player/char/') ? 'Character' : 'Player Portal')
async function signOut() { async function signOut() {
await logout() await logout()
@@ -86,6 +111,7 @@ export default function PlayerPortalLayout() {
}} }}
> >
<div style={{ padding: '22px 22px 18px', borderBottom: '1px solid var(--line-soft)', display: 'flex', alignItems: 'center', gap: 10 }}> <div style={{ padding: '22px 22px 18px', borderBottom: '1px solid var(--line-soft)', display: 'flex', alignItems: 'center', gap: 10 }}>
<BrandLogo height={24} />
<MoonDot /> <MoonDot />
<div> <div>
<div className="display" style={{ fontSize: '1.02rem', color: 'var(--head)', letterSpacing: '0.03em' }}> <div className="display" style={{ fontSize: '1.02rem', color: 'var(--head)', letterSpacing: '0.03em' }}>
@@ -98,7 +124,7 @@ export default function PlayerPortalLayout() {
</div> </div>
<nav style={{ flex: 1, padding: '14px 12px', display: 'flex', flexDirection: 'column', gap: 4, overflowY: 'auto' }}> <nav style={{ flex: 1, padding: '14px 12px', display: 'flex', flexDirection: 'column', gap: 4, overflowY: 'auto' }}>
{NAV.map((n) => ( {nav.map((n) => (
<NavLink <NavLink
key={n.to} key={n.to}
to={n.to} to={n.to}
@@ -111,7 +137,13 @@ export default function PlayerPortalLayout() {
borderLeft: `2px solid ${isActive ? 'var(--accent)' : 'transparent'}`, borderLeft: `2px solid ${isActive ? 'var(--accent)' : 'transparent'}`,
})} })}
> >
<n.icon /> {/* Guarded, like AdminLayout's. `icon` is optional in the nav
contract (§3.3) and every CORE row here has always had one, so
an unguarded `<n.icon />` was fine right up until a module
registered a row without — and then it was not a missing glyph,
it was React error #130 and a blank portal. Found by the §7.7
browser smoke; no DOM-less test can see it. */}
{n.icon && <n.icon />}
<span>{n.label}</span> <span>{n.label}</span>
</NavLink> </NavLink>
))} ))}
@@ -163,3 +195,28 @@ export default function PlayerPortalLayout() {
</div> </div>
) )
} }
/**
* What `/player` renders.
*
* It used to be `PlayerCharacters`, a UO page, which left the portal with no
* index at all when the client half was extracted (slice 3). Rather than pick a
* fixed destination or invent a core landing page, the index resolves to the
* first row of the portal nav this viewer can actually reach — so with the UO
* module installed a player still arrives at their characters, exactly as
* before, and with nothing installed they arrive at Account.
*
* Resolved from the BASE nav, before overrides: where everybody lands is
* behaviour, and an override is presentation (`firstDestinationFor`). `replace`
* so the back button leaves the portal rather than bouncing off this redirect.
*
* The same question exists one area over — the admin index is a hardcoded
* Dashboard — and if the two logged-in areas ever become one, this is the shape
* that answers for both. Nothing here assumes a portal separate from admin.
*/
export function PlayerIndex() {
const { user } = useAuth()
const baseNav = useMemo(() => withModuleNav(NAV, 'player'), [])
const to = firstDestinationFor(baseNav, user?.role, '/account')
return <Navigate to={to} replace />
}

View File

@@ -1,5 +1,6 @@
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import MoonDot from '../../components/MoonDot.jsx' import MoonDot from '../../components/MoonDot.jsx'
import BrandLogo from '../../components/BrandLogo.jsx'
import { useSite } from '../../contexts/SiteContext.jsx' import { useSite } from '../../contexts/SiteContext.jsx'
// Centered card layout shared by the player login / register pages. `subtitle` // Centered card layout shared by the player login / register pages. `subtitle`
@@ -25,6 +26,10 @@ export default function PlayerShell({ subtitle, children, footer }) {
<div style={{ width: '100%', maxWidth: 400 }}> <div style={{ width: '100%', maxWidth: 400 }}>
<div style={{ textAlign: 'center', marginBottom: 26 }}> <div style={{ textAlign: 'center', marginBottom: 26 }}>
<div style={{ marginBottom: 14 }}> <div style={{ marginBottom: 14 }}>
{/* Stacked above the moon rather than beside it: this layout is
centered text, and a flex row here would change the block's
height on instances with no logo. */}
<BrandLogo height={34} style={{ margin: '0 auto 12px' }} />
<MoonDot size={15} glow={0.55} /> <MoonDot size={15} glow={0.55} />
</div> </div>
<h1 className="display" style={{ margin: 0, fontSize: '1.7rem', letterSpacing: '0.04em', color: 'var(--head)' }}> <h1 className="display" style={{ margin: 0, fontSize: '1.7rem', letterSpacing: '0.04em', color: 'var(--head)' }}>

View File

@@ -10,19 +10,19 @@ export default function About() {
<PageHeader eyebrow="About" title={`About ${siteShortName}`} /> <PageHeader eyebrow="About" title={`About ${siteShortName}`} />
<div className="prose"> <div className="prose">
<p> <p>
{siteShortName} is an independent, privately-run Ultima Online shard built by a small group of long-time players. {siteShortName} is an independent, privately-run game server built by a small group of long-time
It is not affiliated with or endorsed by the owners of Ultima Online it is a labor of love for the old players. It is not affiliated with or endorsed by the owners of the game it runs it is a labor of
worlds and the friendships made in them. love for the old worlds and the friendships made in them.
</p> </p>
<p> <p>
Our aim is a calm, hand-tended world: a contested wilderness worth exploring, safe towns worth living in, Our aim is a calm, hand-tended world: somewhere worth exploring, somewhere worth living in, and
and systems that reward curiosity over grind. We are building slowly and in the open, sharing news, systems that reward curiosity over grind. We are building slowly and in the open, sharing news,
screenshots, and guides as the world comes online. screenshots, and guides as the world comes online.
</p> </p>
<h2>What to expect</h2> <h2>What to expect</h2>
<ul> <ul>
<li>A hybrid ruleset safe towns, a dangerous wild.</li> <li>A world that is hand-tended rather than left to run itself.</li>
<li>Custom crafting, housing, and exploration content.</li> <li>Custom content, and changes explained before they land.</li>
<li>A small, friendly population and an active wiki.</li> <li>A small, friendly population and an active wiki.</li>
</ul> </ul>
</div> </div>

View File

@@ -1,202 +0,0 @@
import { useMemo } from 'react'
import PublicLayout from '../../components/PublicLayout.jsx'
import PageHeader from '../../components/PageHeader.jsx'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { useShardFeed } from '../../lib/useShardFeed.js'
import { api } from '../../api/client.js'
// The champion-spawn board. Loaded once from /public/shard/champs, then kept live
// by merging champ.update / champ.remove deltas from the public SSE feed. Three
// families share the board, split by category into their own sections.
const CHAMP_KINDS = new Set(['champ.update', 'champ.remove'])
const SECTIONS = [
{ id: 'champion', title: 'Champion altars', blurb: 'Felucca-style altar spawns.' },
{ id: 'mini', title: 'Mini champs', blurb: 'TerMur controllers — they re-arm on their own.' },
{ id: 'sea', title: 'Sea bosses', blurb: 'High Seas world bosses, alive only while summoned.' },
]
const STATUS_STYLE = {
active: { bg: 'rgba(95,185,138,0.16)', fg: '#8fdcae', border: 'rgba(95,185,138,0.45)', label: 'Active' },
cooldown: { bg: 'rgba(230,194,106,0.14)', fg: '#e6c26a', border: 'rgba(230,194,106,0.4)', label: 'Cooldown' },
dormant: { bg: 'rgba(140,150,165,0.14)', fg: '#aab3c0', border: 'rgba(140,150,165,0.35)', label: 'Dormant' },
}
// A short "in 4m" / "in 2h" for a future ISO timestamp (restartAt / expireAt).
function until(iso) {
if (!iso) return ''
const ms = new Date(iso).getTime() - Date.now()
if (!Number.isFinite(ms)) return ''
if (ms <= 0) return 'due'
const mins = Math.round(ms / 60000)
if (mins < 60) return `in ${mins}m`
const hrs = Math.round(mins / 60)
return `in ${hrs}h`
}
function StatusBadge({ status }) {
const s = STATUS_STYLE[status] || STATUS_STYLE.dormant
return (
<span
className="sans"
style={{
flex: 'none',
fontSize: '0.68rem',
letterSpacing: '0.08em',
textTransform: 'uppercase',
padding: '3px 9px',
borderRadius: 999,
color: s.fg,
background: s.bg,
border: `1px solid ${s.border}`,
}}
>
{s.label}
</span>
)
}
// A slim progress bar (kills toward the next level, or a sea boss's hit points).
function Meter({ value, max, tone = 'var(--accent)' }) {
if (!max) return null
const pct = Math.max(0, Math.min(100, (Number(value) / Number(max)) * 100))
return (
<div style={{ height: 6, borderRadius: 4, background: 'rgba(255,255,255,0.07)', overflow: 'hidden' }}>
<div style={{ width: `${pct}%`, height: '100%', background: tone, borderRadius: 4 }} />
</div>
)
}
// Category-specific middle line + meter for one spawn.
function ChampDetail({ s }) {
const line = { display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.8rem', color: 'var(--muted)', marginTop: 8 }
if (s.category === 'sea') {
return (
<>
<div className="sans" style={line}>
<span>{s.boss || s.type}</span>
{s.hitsMax != null && <span>{Number(s.hits).toLocaleString()} / {Number(s.hitsMax).toLocaleString()} hp</span>}
</div>
<div style={{ marginTop: 6 }}><Meter value={s.hits} max={s.hitsMax} tone="#d9736f" /></div>
</>
)
}
if (s.category === 'mini') {
return (
<div className="sans" style={line}>
<span>Level {s.level ?? 0}{s.maxLevel != null ? ` / ${s.maxLevel}` : ''}</span>
<span>{s.status === 'active' ? 'Running' : 'Re-arming'}</span>
</div>
)
}
// champion
let progress = ''
if (s.status === 'cooldown') progress = until(s.restartAt) || 'restarting'
else if (s.status === 'active') {
progress = `${Number(s.kills || 0).toLocaleString()} / ${Number(s.maxKills || 0).toLocaleString()} kills`
}
return (
<>
<div className="sans" style={line}>
<span>
Level {s.level ?? 0}
{s.bossUp && s.boss ? `${s.boss}` : ''}
</span>
<span>{progress}</span>
</div>
{s.status === 'active' && (
<div style={{ marginTop: 6 }}><Meter value={s.kills} max={s.maxKills} /></div>
)}
</>
)
}
function ChampCard({ s }) {
return (
<div className="panel" style={{ padding: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
<strong className="display" style={{ fontSize: '1.02rem', color: 'var(--head)', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{s.name || s.type || 'Spawn'}
</strong>
<StatusBadge status={s.status} />
</div>
<ChampDetail s={s} />
<div className="sans dim" style={{ marginTop: 10, fontSize: '0.74rem' }}>
{s.map || '—'}{s.x != null ? ` (${s.x}, ${s.y})` : ''}
</div>
</div>
)
}
export default function ChampSpawns() {
const { loading, error, data } = useAsync(() => api.shard.champs())
const { events, connected } = useShardFeed({ filter: CHAMP_KINDS, max: 60 })
// Merge the initial snapshot with live deltas: seed a map by serial, then apply
// buffered events oldest → newest (the buffer is newest-first) so live wins.
const board = useMemo(() => {
const map = new Map()
for (const s of data || []) if (s && s.serial) map.set(s.serial, s)
for (let i = events.length - 1; i >= 0; i -= 1) {
const ev = events[i]
if (!ev || !ev.serial) continue
if (ev.kind === 'champ.update') map.set(ev.serial, ev)
else if (ev.kind === 'champ.remove') map.delete(ev.serial)
}
return [...map.values()]
}, [data, events])
const byCategory = (id) =>
board.filter((s) => (s.category || 'champion') === id).sort((a, b) => (a.name || '').localeCompare(b.name || ''))
const activeCount = board.filter((s) => s.status === 'active').length
return (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
<PageHeader eyebrow="Live" title="Champion spawns" lead="Every altar, mini-champ and sea boss across the shard, updating in real time." />
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
{connected ? 'Live' : 'Offline'}
</span>
</div>
{loading && <Loading />}
{error && <ErrorState message="Could not load the champion board right now." />}
{!loading && !error && (
<>
{board.length === 0 ? (
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
<p className="sans dim" style={{ margin: 0 }}>No champion spawns are being tracked right now.</p>
</section>
) : (
<>
<p className="sans" style={{ color: 'var(--accent)', fontSize: '0.8rem', marginTop: -12, marginBottom: 24 }}>
{activeCount} active · {board.length} tracked
</p>
{SECTIONS.map((sec) => {
const rows = byCategory(sec.id)
if (rows.length === 0) return null
return (
<section key={sec.id} style={{ marginBottom: 28 }}>
<div style={{ marginBottom: 12 }}>
<h2 className="display" style={{ margin: 0, fontSize: '1.1rem', color: 'var(--head)' }}>{sec.title}</h2>
<p className="sans dim" style={{ margin: '2px 0 0', fontSize: '0.8rem' }}>{sec.blurb}</p>
</div>
<div className="grid-2" style={{ gap: 12 }}>
{rows.map((s) => <ChampCard key={s.serial} s={s} />)}
</div>
</section>
)
})}
</>
)}
</>
)}
</div>
</PublicLayout>
)
}

View File

@@ -1,187 +0,0 @@
import { useMemo, useState } from 'react'
import PublicLayout from '../../components/PublicLayout.jsx'
import PageHeader from '../../components/PageHeader.jsx'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { useShardFeed } from '../../lib/useShardFeed.js'
import { crestFor } from '../../data/cityCrests.js'
import { api } from '../../api/client.js'
// The town-governor board (City Loyalty). Loaded from /public/shard/governors,
// kept live by merging city.update deltas by city. Empty on shards without the
// City Loyalty system. Each city card links to its term history (look-back).
const GOV_KINDS = new Set(['city.update'])
const PHASE = {
none: null,
nominate: { label: 'Nominations open', color: '#7f8fd0' },
vote: { label: 'Voting', color: '#e6c26a' },
pending: { label: 'Result pending', color: '#c9a24b' },
}
// A short "in 3d" / "in 5h" for a future ISO timestamp (autoPickAt).
function until(iso) {
if (!iso) return ''
const ms = new Date(iso).getTime() - Date.now()
if (!Number.isFinite(ms) || ms <= 0) return ''
const mins = Math.round(ms / 60000)
if (mins < 60) return `in ${mins}m`
const hrs = Math.round(mins / 60)
if (hrs < 24) return `in ${hrs}h`
return `in ${Math.round(hrs / 24)}d`
}
function fmtDate(ms) {
if (ms == null) return ''
return new Date(Number(ms)).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
}
function CityCrest({ city, size = 44 }) {
const c = crestFor(city)
return (
<span
aria-hidden="true"
style={{
flex: 'none', width: size, height: size, borderRadius: '50%',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
fontSize: size * 0.5, background: 'rgba(255,255,255,0.04)',
border: `2px solid ${c.color}`, boxShadow: `0 0 10px ${c.color}22`,
}}
>
{c.sigil}
</span>
)
}
// Collapsible term history for one city, fetched on demand from the ledger.
function TermHistory({ city }) {
const [open, setOpen] = useState(false)
const { loading, error, data } = useAsync(
() => (open ? api.shard.governorHistory(city, 25) : Promise.resolve(null)),
[open, city],
)
return (
<div style={{ marginTop: 12 }}>
<button
type="button"
className="sans"
onClick={() => setOpen((v) => !v)}
style={{ background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer', padding: 0, fontSize: '0.76rem' }}
>
{open ? 'Hide past governors' : 'Past governors →'}
</button>
{open && (
<div style={{ marginTop: 8 }}>
{loading && <p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>Loading</p>}
{error && <p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>Could not load history.</p>}
{data && data.length === 0 && (
<p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>No recorded terms yet.</p>
)}
{data && data.length > 0 && (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 5 }}>
{data.map((t) => (
<li key={`${t.startedAt}-${t.governor?.name ?? 'vacant'}`} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 10, fontSize: '0.8rem', color: 'var(--ink)' }}>
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{t.governor?.name || 'Vacant'}
</span>
<span className="dim" style={{ flex: 'none', fontSize: '0.72rem' }}>
{fmtDate(t.startedAt)}{t.endedAt ? ` ${fmtDate(t.endedAt)}` : ' present'}
</span>
</li>
))}
</ul>
)}
</div>
)}
</div>
)
}
function CityCard({ c }) {
const phase = PHASE[c.electionPhase] || null
const gov = c.governor
const candidatePlural = c.candidates === 1 ? '' : 's'
return (
<div className="panel" style={{ padding: 18 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<CityCrest city={c.city} />
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<strong className="display" style={{ fontSize: '1.05rem', color: 'var(--head)' }}>
{crestFor(c.city).label || c.city}
</strong>
{phase && (
<span className="sans" style={{ flex: 'none', fontSize: '0.66rem', letterSpacing: '0.06em', textTransform: 'uppercase', color: phase.color, border: `1px solid ${phase.color}66`, borderRadius: 999, padding: '2px 8px' }}>
{phase.label}
</span>
)}
</div>
<div className="sans" style={{ marginTop: 3, fontSize: '0.9rem', color: gov ? 'var(--ink)' : 'var(--muted)' }}>
{gov ? (
<>Governor <strong style={{ color: 'var(--head)' }}>{gov.name}</strong></>
) : (
'Seat vacant'
)}
</div>
</div>
</div>
{c.electionPhase && c.electionPhase !== 'none' && (
<div className="sans dim" style={{ marginTop: 10, fontSize: '0.78rem' }}>
{c.candidates ? `${c.candidates} candidate${candidatePlural}` : 'No candidates yet'}
{c.autoPickAt && until(c.autoPickAt) ? ` · resolves ${until(c.autoPickAt)}` : ''}
</div>
)}
<TermHistory city={c.city} />
</div>
)
}
export default function Governors() {
const { loading, error, data } = useAsync(() => api.shard.governors())
const { events, connected } = useShardFeed({ filter: GOV_KINDS, max: 30 })
const board = useMemo(() => {
const map = new Map()
for (const c of data || []) if (c && c.city) map.set(c.city, c)
for (let i = events.length - 1; i >= 0; i -= 1) {
const ev = events[i]
if (ev.kind === 'city.update' && ev.city) map.set(ev.city, ev)
}
return [...map.values()].sort((a, b) => (a.city || '').localeCompare(b.city || ''))
}, [data, events])
return (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
<PageHeader eyebrow="Live" title="Governors of Britannia" lead="Who rules each city, and where the next election stands." />
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
{connected ? 'Live' : 'Offline'}
</span>
</div>
{loading && <Loading />}
{error && <ErrorState message="Could not load the governor board right now." />}
{!loading && !error && (
<>
{board.length === 0 ? (
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
<p className="sans dim" style={{ margin: 0 }}>
City Loyalty governance is not enabled on this shard.
</p>
</section>
) : (
<div className="grid-2" style={{ gap: 12 }}>
{board.map((c) => <CityCard key={c.city} c={c} />)}
</div>
)}
</>
)}
</div>
</PublicLayout>
)
}

View File

@@ -1,169 +0,0 @@
import { useMemo, useState } from 'react'
import PublicLayout from '../../components/PublicLayout.jsx'
import PageHeader from '../../components/PageHeader.jsx'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { useShardFeed } from '../../lib/useShardFeed.js'
import { api } from '../../api/client.js'
// The guild board. Loaded once from /public/shard/guilds, then kept live by
// merging guild.update / guild.remove deltas; guild.join drives a small "recently
// joined" strip on top of the board.
const GUILD_KINDS = new Set(['guild.update', 'guild.remove', 'guild.join'])
function Leader({ leader }) {
if (!leader || !leader.name) return <span className="dim"></span>
return <span>{leader.name}</span>
}
function GuildRow({ g }) {
return (
<div
className="panel"
style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}
>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, minWidth: 0 }}>
{g.abbr && (
<span
className="sans"
style={{
flex: 'none',
fontSize: '0.72rem',
letterSpacing: '0.06em',
color: 'var(--accent)',
border: '1px solid rgba(201,162,75,0.4)',
borderRadius: 5,
padding: '1px 6px',
}}
>
{g.abbr}
</span>
)}
<strong
className="display"
style={{ fontSize: '1rem', color: 'var(--head)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
>
{g.name || 'A guild'}
</strong>
</div>
{g.alliance && (
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
{g.alliance}
</div>
)}
</div>
<div className="sans" style={{ flex: 'none', textAlign: 'right', fontSize: '0.84rem', color: 'var(--ink)' }}>
<div>
<span style={{ color: '#7fd0a4' }}>{g.online ?? 0}</span>
<span className="dim"> / {g.members ?? 0}</span>
</div>
<div className="dim" style={{ fontSize: '0.72rem', marginTop: 2 }}>
<Leader leader={g.leader} />
</div>
</div>
</div>
)
}
export default function Guilds() {
const { loading, error, data } = useAsync(() => api.shard.guilds())
const { events, connected } = useShardFeed({ filter: GUILD_KINDS, max: 60 })
const [q, setQ] = useState('')
// Merge snapshot + live deltas by guild id (apply oldest → newest so live wins).
const board = useMemo(() => {
const map = new Map()
for (const g of data || []) if (g && g.id != null) map.set(g.id, g)
for (let i = events.length - 1; i >= 0; i -= 1) {
const ev = events[i]
if (ev.kind === 'guild.update' && ev.id != null) map.set(ev.id, ev)
else if (ev.kind === 'guild.remove' && ev.id != null) map.delete(ev.id)
}
return [...map.values()]
}, [data, events])
// Recent joins strip (newest first, deduped, capped).
const joins = useMemo(
() => events.filter((e) => e.kind === 'guild.join' && e.who).slice(0, 6),
[events],
)
const filtered = useMemo(() => {
const needle = q.trim().toLowerCase()
const rows = needle
? board.filter((g) =>
[g.name, g.abbr, g.alliance].some((v) => v && v.toLowerCase().includes(needle)),
)
: board
return [...rows].sort((a, b) => (a.name || '').localeCompare(b.name || ''))
}, [board, q])
const totalMembers = board.reduce((n, g) => n + (Number(g.members) || 0), 0)
return (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
<PageHeader eyebrow="Live" title="Guilds" lead="Every guild on the shard — rosters, alliances and who's online, updating in real time." />
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
{connected ? 'Live' : 'Offline'}
</span>
</div>
{loading && <Loading />}
{error && <ErrorState message="Could not load the guild board right now." />}
{!loading && !error && (
<>
{board.length === 0 ? (
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
<p className="sans dim" style={{ margin: 0 }}>No guilds are being tracked right now.</p>
</section>
) : (
<>
{joins.length > 0 && (
<section className="panel" style={{ padding: '12px 16px', marginBottom: 18 }}>
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.66rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 8 }}>
Recently joined
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
{joins.map((j) => (
<div key={j._id} className="sans" style={{ fontSize: '0.84rem', color: 'var(--ink)' }}>
<strong style={{ color: 'var(--head)' }}>{j.who.name}</strong>
<span className="dim"> joined </span>
{j.abbr ? `[${j.abbr}] ` : ''}{j.name}
</div>
))}
</div>
</section>
)}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 14 }}>
<p className="sans" style={{ color: 'var(--accent)', fontSize: '0.8rem', margin: 0 }}>
{board.length} guilds · {totalMembers.toLocaleString()} members
</p>
<input
className="input sans"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Search guilds…"
style={{ flex: 'none', width: 190, maxWidth: '50%', fontSize: '0.84rem' }}
/>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{filtered.map((g) => <GuildRow key={g.id} g={g} />)}
</div>
{filtered.length === 0 && (
<p className="sans dim" style={{ textAlign: 'center', marginTop: 20 }}>No guilds match {q}.</p>
)}
</>
)}
</>
)}
</div>
</PublicLayout>
)
}

View File

@@ -1,91 +0,0 @@
import { useMemo } from 'react'
import PublicLayout from '../../components/PublicLayout.jsx'
import PageHeader from '../../components/PageHeader.jsx'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { useShardFeed } from '../../lib/useShardFeed.js'
import { api } from '../../api/client.js'
// PUBLIC houses board: only houses in danger (IDOC), shown by location. Owner,
// price, decay detail and the full registry are staff-only (admin Houses view).
// Loaded from /public/shard/houses (IDOC-only), kept live by house.decay: a
// house entering IDOC appears, one leaving it drops off.
const HOUSE_KINDS = new Set(['house.decay'])
function HouseRow({ h }) {
return (
<div className="panel" style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}>
<span
aria-hidden="true"
style={{ flex: 'none', width: 8, height: 8, borderRadius: '50%', background: '#e05a5a', boxShadow: '0 0 8px rgba(224,90,90,0.7)' }}
/>
<div style={{ minWidth: 0, flex: 1 }}>
<div className="display" style={{ fontSize: '1rem', color: 'var(--head)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{h.region || 'The wilderness'}
</div>
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
{h.map || '—'}{h.x != null ? ` · ${h.x}, ${h.y}` : ''}
</div>
</div>
<span className="sans" style={{ flex: 'none', fontSize: '0.68rem', letterSpacing: '0.06em', color: '#e05a5a', border: '1px solid #e05a5a66', borderRadius: 999, padding: '2px 9px' }}>
IDOC
</span>
</div>
)
}
export default function Houses() {
const { loading, error, data } = useAsync(() => api.shard.houses())
const { events, connected } = useShardFeed({ filter: HOUSE_KINDS, max: 60 })
// Merge the IDOC snapshot with live house.decay deltas by serial: entering IDOC
// adds/updates the row; anything else (refreshed, collapsed) drops it.
const board = useMemo(() => {
const map = new Map()
for (const h of data || []) if (h && h.serial) map.set(h.serial, h)
for (let i = events.length - 1; i >= 0; i -= 1) {
const ev = events[i]
if (ev.kind !== 'house.decay' || !ev.serial) continue
if (String(ev.to).toUpperCase() === 'IDOC') {
map.set(ev.serial, { serial: ev.serial, name: ev.name, region: ev.region, map: ev.map, x: ev.x, y: ev.y, z: ev.z, isIdoc: true })
} else {
map.delete(ev.serial)
}
}
return [...map.values()].sort((a, b) => (a.region || '').localeCompare(b.region || ''))
}, [data, events])
return (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
<PageHeader eyebrow="Live" title="Houses in danger" lead="Homes that have fallen into IDOC — where to find them before they collapse." />
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
{connected ? 'Live' : 'Offline'}
</span>
</div>
{loading && <Loading />}
{error && <ErrorState message="Could not load the houses board right now." />}
{!loading && !error && (
board.length === 0 ? (
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
<p className="sans dim" style={{ margin: 0 }}>No houses are collapsing right now.</p>
</section>
) : (
<>
<p className="sans" style={{ color: '#e0928a', fontSize: '0.8rem', marginTop: -12, marginBottom: 20 }}>
{board.length} in danger
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{board.map((h) => <HouseRow key={h.serial} h={h} />)}
</div>
</>
)
)}
</div>
</PublicLayout>
)
}

View File

@@ -1,5 +1,6 @@
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import MoonDot from '../../components/MoonDot.jsx' import MoonDot from '../../components/MoonDot.jsx'
import BrandLogo from '../../components/BrandLogo.jsx'
import { useSite } from '../../contexts/SiteContext.jsx' import { useSite } from '../../contexts/SiteContext.jsx'
export default function Maintenance() { export default function Maintenance() {
@@ -28,6 +29,7 @@ export default function Maintenance() {
> >
<div style={{ maxWidth: 640, textShadow: '0 2px 22px rgba(0,0,0,0.85)' }}> <div style={{ maxWidth: 640, textShadow: '0 2px 22px rgba(0,0,0,0.85)' }}>
<div style={{ marginBottom: 26 }}> <div style={{ marginBottom: 26 }}>
<BrandLogo height={40} style={{ margin: '0 auto 16px' }} />
<MoonDot size={18} glow={0.6} /> <MoonDot size={18} glow={0.6} />
</div> </div>
<p className="eyebrow" style={{ color: '#c2d2e6', letterSpacing: '0.24em' }}> <p className="eyebrow" style={{ color: '#c2d2e6', letterSpacing: '0.24em' }}>

View File

@@ -16,7 +16,7 @@ export default function Screenshots() {
<PageHeader <PageHeader
eyebrow="Gallery" eyebrow="Gallery"
title="Gameplay Pictures" title="Gameplay Pictures"
lead="Glimpses of towns, dungeons, events, and daily life on the shard." lead="Glimpses of the world, its events, and daily life on the server."
/> />
{loading && <Loading />} {loading && <Loading />}
{error && <ErrorState message="Could not load the gallery right now." />} {error && <ErrorState message="Could not load the gallery right now." />}

View File

@@ -1,254 +0,0 @@
import { Link } from 'react-router-dom'
import PublicLayout from '../../components/PublicLayout.jsx'
import PageHeader from '../../components/PageHeader.jsx'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { useShardFeed } from '../../lib/useShardFeed.js'
import { describe } from '../../lib/shardEvents.js'
import { ago } from '../../lib/format.js'
import { api } from '../../api/client.js'
import PlayersOnline from '../../components/PlayersOnline.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx'
// Flavor line under the online/offline banner: online, configured-but-down, or
// not configured yet.
function statusMessage(online, enabled) {
if (online) return 'The gate to Britannia stands open.'
if (enabled) return 'The link to the game world is down — checking back automatically.'
return 'Live shard data is not configured yet.'
}
// ── Gold-supply sparkline ───────────────────────────────────────────────────
function Sparkline({ series }) {
if (!series || series.length < 2) return null
const w = 320
const h = 56
const golds = series.map((s) => Number(s.gold) || 0)
const min = Math.min(...golds)
const max = Math.max(...golds)
const span = max - min || 1
const pts = series
.map((s, i) => {
const x = (i / (series.length - 1)) * w
const y = h - ((Number(s.gold) || 0) - min) / span * h
return `${x.toFixed(1)},${y.toFixed(1)}`
})
.join(' ')
return (
<svg viewBox={`0 0 ${w} ${h}`} width="100%" height={h} preserveAspectRatio="none" aria-hidden="true">
<polyline points={pts} fill="none" stroke="var(--accent)" strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />
</svg>
)
}
// ── Stat tile (matches Status.jsx) ──────────────────────────────────────────
function Stat({ value, label }) {
return (
<div className="panel" style={{ padding: 20, textAlign: 'center' }}>
<div className="display" style={{ fontSize: '1.6rem', color: 'var(--head)' }}>{value}</div>
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginTop: 6 }}>
{label}
</div>
</div>
)
}
export default function Shard() {
const { loading, error, data } = useAsync(() =>
Promise.all([
api.shard.status(),
api.shard.idoc(),
api.shard.economy(60),
api.shard.online(),
]).then(([status, idoc, economy, online]) => ({ status, idoc, economy, online })),
)
const { events, connected } = useShardFeed({ max: 30 })
const { user } = useAuth()
// Staff in-game location is privileged: only admins/moderators see it. Players
// and the public see that staff are online but not where. The server enforces
// this too (it omits the location fields entirely for non-privileged callers).
const canSeeLocation = user?.role === 'admin' || user?.role === 'moderator'
const status = data?.status
const online = status?.pluginConnected
const gold = status?.economy?.gold
return (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<PageHeader eyebrow="Live" title="Shard" />
{loading && <Loading />}
{error && <ErrorState message="Could not load shard data right now." />}
{!loading && !error && data && (
<>
<ConnectionBanner online={online} status={status} />
{/* Stat tiles */}
<section className="grid-2" style={{ gap: 14, marginBottom: 24 }}>
<Stat value={gold != null ? `${Number(gold).toLocaleString()}` : '—'} label="Gold supply" />
<Stat value={online ? 'Up' : 'Down'} label="Shard link" />
</section>
{/* Live players-online breakdown (total + region buckets) */}
<div style={{ marginBottom: 24 }}>
<PlayersOnline />
</div>
<StaffOnline list={data.online} canSeeLocation={canSeeLocation} />
{/* Economy sparkline */}
{data.economy && data.economy.length > 1 && (
<section className="panel" style={{ padding: 20, marginBottom: 24 }}>
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 10 }}>
Gold supply over time
</div>
<Sparkline series={data.economy} />
</section>
)}
<div style={{ marginBottom: 24 }}>
{/* Latest IDOC */}
<FeedList
title="Houses in danger (IDOC)"
empty="No houses are collapsing right now."
items={data.idoc.map((h) => {
const region = h.region ? `${h.region}` : ''
return {
id: h.serial,
text: `${h.name || 'A house'}${region}`,
when: h.updatedAt,
}
})}
/>
</div>
{/* Live ticker */}
<section className="panel" style={{ padding: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase' }}>
Live feed
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<Link to="/site/shard/activity" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.78rem' }}>
View all activity
</Link>
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)' }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
{connected ? 'Live' : 'Offline'}
</span>
</div>
</div>
{events.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>
Waiting for something to happen in the world
</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
{events.map((ev) => (
<li key={ev._id} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{describe(ev)}</span>
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>{ago(ev.t)}</span>
</li>
))}
</ul>
)}
</section>
</>
)}
</div>
</PublicLayout>
)
}
// Online/offline banner with the flavor line under it.
function ConnectionBanner({ online, status }) {
return (
<section
style={{
display: 'flex',
alignItems: 'center',
gap: 16,
padding: '24px 26px',
border: `1px solid ${online ? 'rgba(95,185,138,0.45)' : '#5a4a2a'}`,
borderRadius: 10,
background: online
? 'linear-gradient(180deg,rgba(22,46,34,0.5),rgba(16,26,20,0.4))'
: 'linear-gradient(180deg,rgba(58,46,22,0.5),rgba(30,26,16,0.4))',
marginBottom: 24,
}}
>
<span
style={{
flex: 'none',
width: 12,
height: 12,
borderRadius: '50%',
background: online ? 'var(--mode-live)' : 'var(--mode-maint)',
boxShadow: `0 0 12px ${online ? 'rgba(95,185,138,0.7)' : 'rgba(230,194,106,0.7)'}`,
}}
/>
<div>
<strong className="display" style={{ display: 'block', fontSize: '1.2rem', color: online ? '#bfe6cf' : '#f0e3c4' }}>
{online ? 'The shard is online' : 'The shard is offline'}
</strong>
<span className="sans" style={{ color: online ? '#a9cdb8' : '#cdbf9a', fontSize: '0.98rem' }}>
{statusMessage(online, status?.enabled)}
</span>
</div>
</section>
)
}
// Linked staff accounts currently online; in-game location is admin/mod-only.
function StaffOnline({ list, canSeeLocation }) {
return (
<section className="panel" style={{ padding: 20, marginBottom: 24 }}>
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 12 }}>
Staff online
</div>
{(!list || list.length === 0) ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>No staff are online right now.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{list.map((p) => (
<div key={p.serial} className="sans" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<span style={{ flex: 'none', width: 8, height: 8, borderRadius: '50%', background: '#7fd0a4' }} />
{p.name || p.serial}
</span>
{canSeeLocation && (
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>
{p.map || '—'}{p.x != null ? ` (${p.x}, ${p.y})` : ''}
</span>
)}
</div>
))}
</div>
)}
</section>
)
}
function FeedList({ title, items, empty }) {
return (
<section className="panel" style={{ padding: 20 }}>
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 12 }}>
{title}
</div>
{items.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>{empty}</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
{items.map((it) => (
<li key={it.id} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{it.text}</span>
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>{ago(it.when)}</span>
</li>
))}
</ul>
)}
</section>
)
}

View File

@@ -1,81 +0,0 @@
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import PublicLayout from '../../components/PublicLayout.jsx'
import PageHeader from '../../components/PageHeader.jsx'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { useShardFeed } from '../../lib/useShardFeed.js'
import { describe, categoryOf, kindLabel, CATEGORIES } from '../../lib/shardEvents.js'
import { ago } from '../../lib/format.js'
import { api } from '../../api/client.js'
// Public activity feed: the full shard event log, filterable by category, with a
// live tail that prepends new events as they happen.
export default function ShardActivity() {
const { loading, error, data } = useAsync(() => api.shard.feed({ limit: 150 }))
const { events: live } = useShardFeed({ max: 60 })
const [cat, setCat] = useState('all')
// Merge the live tail with the loaded history, de-duped by kind+t, newest first.
const merged = useMemo(() => {
const seen = new Set()
const out = []
for (const e of [...live, ...(data || [])]) {
const key = `${e.kind}-${e.t}`
if (seen.has(key)) continue
seen.add(key)
out.push(e)
}
return out.sort((a, b) => (b.t || 0) - (a.t || 0))
}, [live, data])
const filtered = cat === 'all' ? merged : merged.filter((e) => categoryOf(e.kind) === cat)
return (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<PageHeader eyebrow="Live" title="Shard Activity" />
<p style={{ marginTop: -8, marginBottom: 18 }}>
<Link to="/site/shard" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.86rem' }}> Back to shard</Link>
</p>
{/* Category tabs */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 18 }}>
{CATEGORIES.map((c) => (
<button
key={c.id}
onClick={() => setCat(c.id)}
className="pill"
style={cat === c.id ? { background: 'var(--accent)', color: 'var(--bg-deep)', borderColor: 'var(--accent)' } : undefined}
>
{c.label}
</button>
))}
</div>
{loading && <Loading />}
{error && <ErrorState message="Could not load the activity feed right now." />}
{!loading && !error && (
filtered.length === 0 ? (
<div className="panel" style={{ padding: 22 }}>
<p className="sans dim" style={{ margin: 0, fontSize: '0.9rem' }}>Nothing here yet events will appear as they happen in the world.</p>
</div>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
{filtered.map((e) => (
<li key={e._id || `${e.kind}-${e.t}`} className="panel" style={{ padding: '12px 16px', display: 'flex', alignItems: 'center', gap: 12 }}>
<span className="sans" style={{ flex: 'none', fontSize: '0.62rem', letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--accent)', minWidth: 92 }}>
{kindLabel(e.kind)}
</span>
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--ink)', fontSize: '0.92rem' }}>{describe(e)}</span>
<span className="sans dim" style={{ flex: 'none', fontSize: '0.76rem' }}>{ago(e.t)}</span>
</li>
))}
</ul>
)
)}
</div>
</PublicLayout>
)
}

View File

@@ -19,7 +19,7 @@ export default function Status() {
return ( return (
<PublicLayout section="website"> <PublicLayout section="website">
<div className="shell-narrow page-body"> <div className="shell-narrow page-body">
<PageHeader eyebrow="Live" title="Shard Status" /> <PageHeader eyebrow="Live" title="Site Status" />
{loading && <Loading />} {loading && <Loading />}
{error && <ErrorState message="Could not load status right now." />} {error && <ErrorState message="Could not load status right now." />}
@@ -58,7 +58,7 @@ export default function Status() {
{isLive ? 'Live — the gates are open' : 'Maintenance — building in progress'} {isLive ? 'Live — the gates are open' : 'Maintenance — building in progress'}
</strong> </strong>
<span style={{ color: isLive ? '#a9cdb8' : '#cdbf9a', fontSize: '0.98rem' }}> <span style={{ color: isLive ? '#a9cdb8' : '#cdbf9a', fontSize: '0.98rem' }}>
{statusMessage || (isLive ? 'The shard is online.' : 'The gates are closed while we shape the world. Public login is not open yet.')} {statusMessage || (isLive ? 'The site is open.' : 'The gates are closed while we shape the world. Public login is not open yet.')}
</span> </span>
</div> </div>
</section> </section>

View File

@@ -4,12 +4,12 @@ import PageHeader from '../../components/PageHeader.jsx'
import { useSite } from '../../contexts/SiteContext.jsx' import { useSite } from '../../contexts/SiteContext.jsx'
const CARDS = [ const CARDS = [
{ kicker: 'Gallery', title: 'Gameplay Pictures', body: 'Screenshots from towns, dungeons, events, and daily life on the shard.', to: '/site/screenshots' }, { kicker: 'Gallery', title: 'Gameplay Pictures', body: 'Screenshots of the world, its events, and daily life on the server.', to: '/site/screenshots' },
{ kicker: 'Updates', title: 'Development News', body: 'Progress notes, shard milestones, and public announcements.', to: '/site/news' }, { kicker: 'Updates', title: 'Development News', body: 'Progress notes, project milestones, and public announcements.', to: '/site/news' },
{ kicker: 'Community', title: 'Five on Friday', body: 'Weekly questions, small previews, and notes from the team.', to: '/site/five-on-friday' }, { kicker: 'Community', title: 'Five on Friday', body: 'Weekly questions, small previews, and notes from the team.', to: '/site/five-on-friday' },
{ kicker: 'Long-form', title: 'Monthly Newsletter', body: 'Fuller summaries for players who want the whole picture.', to: '/site/newsletter' }, { kicker: 'Long-form', title: 'Monthly Newsletter', body: 'Fuller summaries for players who want the whole picture.', to: '/site/newsletter' },
{ kicker: 'Reference', title: 'Wiki', body: 'Guides and reference pages for the game world.', to: '/wiki' }, { kicker: 'Reference', title: 'Wiki', body: 'Guides and reference pages for the game world.', to: '/wiki' },
{ kicker: 'Live', title: 'Shard Status', body: 'Launch state, test windows, and known issues.', to: '/site/status' }, { kicker: 'Live', title: 'Site Status', body: 'Launch state, test windows, and known issues.', to: '/site/status' },
] ]
export default function Website() { export default function Website() {

View File

@@ -97,7 +97,7 @@ export default function Wiki() {
center center
eyebrow="Knowledge base" eyebrow="Knowledge base"
title={`${siteShortName} Wiki`} title={`${siteShortName} Wiki`}
lead="A calm starting point for shard guides, the world and its lore, gameplay systems, and community rules." lead="A calm starting point for guides, the world and its lore, gameplay systems, and community rules."
/> />
<SearchBox initial={activeQ || ''} onSubmit={runSearch} /> <SearchBox initial={activeQ || ''} onSubmit={runSearch} />

View File

@@ -24,6 +24,22 @@
--shadow-card: 0 14px 34px rgba(0, 0, 0, 0.3); --shadow-card: 0 14px 34px rgba(0, 0, 0, 0.3);
--panel-grad: linear-gradient(180deg, var(--panel-a), var(--panel-b)); --panel-grad: linear-gradient(180deg, var(--panel-a), var(--panel-b));
/* Corner radius, by the kind of surface rather than by the pixel value, so a
theme preset can restyle all of them at once (see
docs/website/THEMING_AND_NAV.md §4.7). Seeded at the values already in use
— this promotion is a no-op, and every existing instance must keep looking
exactly as it does today.
Deliberately four tokens, not three: .card/.panel are 10px and .panel-flat
is 12px, so collapsing them would have restyled every card on every
install. The 7px (.rte-btn) and 6px (.rte-linkmenu-item) values stay
literals — interior editor chrome, not brand surface — as do the 50%
circles, which are shapes rather than radii. */
--radius-pill: 999px;
--radius-panel: 12px;
--radius-card: 10px;
--radius-input: 8px;
} }
* { * {
@@ -99,7 +115,7 @@ a {
flex-direction: column; flex-direction: column;
padding: 24px; padding: 24px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 10px; border-radius: var(--radius-card);
text-decoration: none; text-decoration: none;
color: var(--ink); color: var(--ink);
background: var(--panel-grad); background: var(--panel-grad);
@@ -123,19 +139,19 @@ a.card:focus-visible {
} }
.panel { .panel {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 10px; border-radius: var(--radius-card);
background: var(--panel-grad); background: var(--panel-grad);
} }
.panel-flat { .panel-flat {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 12px; border-radius: var(--radius-panel);
overflow: hidden; overflow: hidden;
background: var(--panel-flat); background: var(--panel-flat);
} }
.note { .note {
border: 1px solid var(--line); border: 1px solid var(--line);
border-left: 3px solid var(--accent); border-left: 3px solid var(--accent);
border-radius: 8px; border-radius: var(--radius-input);
background: rgba(19, 36, 60, 0.4); background: rgba(19, 36, 60, 0.4);
padding: 18px 22px; padding: 18px 22px;
color: var(--muted); color: var(--muted);
@@ -168,12 +184,20 @@ a.card:focus-visible {
/* ===== Pills / buttons ===== */ /* ===== Pills / buttons ===== */
.pill { .pill {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 999px; border-radius: var(--radius-pill);
padding: 7px 14px; padding: 7px 14px;
color: var(--muted); color: var(--muted);
background: rgba(11, 22, 48, 0.5); background: rgba(11, 22, 48, 0.5);
font-family: var(--sans); font-family: var(--sans);
font-size: 0.86rem; font-size: 0.86rem;
/* Stated, not inherited. A <button class="pill"> would otherwise take the UA
stylesheet's `line-height: normal` — form controls do not inherit it from
body — and come out ~7px shorter than an <a class="pill"> beside it. Every
other property here is already explicit for the same reason; this was the
one gap, and it only became visible once the public header put a button
pill (a dropdown trigger) on the same row as the link pills. Matches
body's 1.6, so no link pill changes. */
line-height: 1.6;
text-decoration: none; text-decoration: none;
cursor: pointer; cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s; transition: background 0.15s, border-color 0.15s, color 0.15s;
@@ -186,7 +210,7 @@ a.card:focus-visible {
outline: none; outline: none;
} }
.btn { .btn {
border-radius: 999px; border-radius: var(--radius-pill);
padding: 12px 26px; padding: 12px 26px;
font-family: var(--sans); font-family: var(--sans);
font-size: 0.92rem; font-size: 0.92rem;
@@ -214,7 +238,7 @@ a.card:focus-visible {
background: var(--blue); background: var(--blue);
} }
.btn-sq { .btn-sq {
border-radius: 8px; border-radius: var(--radius-input);
padding: 10px 18px; padding: 10px 18px;
font-size: 0.85rem; font-size: 0.85rem;
} }
@@ -230,7 +254,7 @@ button[disabled] {
.select { .select {
width: 100%; width: 100%;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; border-radius: var(--radius-input);
padding: 11px 14px; padding: 11px 14px;
background: var(--bg); background: var(--bg);
color: var(--ink); color: var(--ink);
@@ -312,7 +336,7 @@ button[disabled] {
} }
.prose img { .prose img {
max-width: 100%; max-width: 100%;
border-radius: 8px; border-radius: var(--radius-input);
border: 1px solid var(--line); border: 1px solid var(--line);
} }
@@ -320,7 +344,7 @@ button[disabled] {
.rte { .rte {
position: relative; position: relative;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; border-radius: var(--radius-input);
background: var(--bg); background: var(--bg);
} }
.rte:focus-within { .rte:focus-within {
@@ -407,7 +431,7 @@ button[disabled] {
width: min(360px, calc(100% - 20px)); width: min(360px, calc(100% - 20px));
padding: 10px; padding: 10px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; border-radius: var(--radius-input);
background: var(--panel-a); background: var(--panel-a);
box-shadow: var(--shadow-card); box-shadow: var(--shadow-card);
} }
@@ -449,7 +473,7 @@ button[disabled] {
display: inline-block; display: inline-block;
padding: 3px 10px; padding: 3px 10px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 999px; border-radius: var(--radius-pill);
background: rgba(127, 153, 189, 0.1); background: rgba(127, 153, 189, 0.1);
color: var(--accent); color: var(--accent);
font-family: var(--sans); font-family: var(--sans);
@@ -494,7 +518,7 @@ button[disabled] {
width: 100%; width: 100%;
padding: 8px 10px; padding: 8px 10px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; border-radius: var(--radius-input);
background: var(--panel-flat); background: var(--panel-flat);
color: var(--text); color: var(--text);
text-align: left; text-align: left;
@@ -532,7 +556,7 @@ button[disabled] {
overflow-y: auto; overflow-y: auto;
padding: 12px 14px; padding: 12px 14px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; border-radius: var(--radius-input);
background: var(--bg); background: var(--bg);
} }
.diff-add { .diff-add {
@@ -603,7 +627,7 @@ button[disabled] {
vertical-align: middle; vertical-align: middle;
} }
.badge { .badge {
border-radius: 999px; border-radius: var(--radius-pill);
padding: 3px 11px; padding: 3px 11px;
font-size: 0.72rem; font-size: 0.72rem;
font-weight: 700; font-weight: 700;
@@ -780,7 +804,7 @@ button[disabled] {
} }
.page-image img { .page-image img {
max-width: 100%; max-width: 100%;
border-radius: 8px; border-radius: var(--radius-input);
border: 1px solid var(--line); border: 1px solid var(--line);
display: block; display: block;
} }
@@ -863,7 +887,7 @@ button[disabled] {
} }
.pb-column-editor { .pb-column-editor {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; border-radius: var(--radius-input);
padding: 12px; padding: 12px;
background: var(--panel-flat, transparent); background: var(--panel-flat, transparent);
} }
@@ -881,7 +905,7 @@ button[disabled] {
} }
.pb-subblock { .pb-subblock {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; border-radius: var(--radius-input);
padding: 10px; padding: 10px;
margin-top: 10px; margin-top: 10px;
background: var(--bg); background: var(--bg);
@@ -919,7 +943,7 @@ button[disabled] {
border: 1px solid #6e3b38; border: 1px solid #6e3b38;
background: rgba(110, 59, 56, 0.16); background: rgba(110, 59, 56, 0.16);
color: #e6a9a3; color: #e6a9a3;
border-radius: 8px; border-radius: var(--radius-input);
padding: 10px 14px; padding: 10px 14px;
margin-top: 14px; margin-top: 14px;
font-size: 0.86rem; font-size: 0.86rem;
@@ -928,7 +952,7 @@ button[disabled] {
border: 1px solid var(--accent); border: 1px solid var(--accent);
background: var(--blue); background: var(--blue);
color: var(--accent-bright); color: var(--accent-bright);
border-radius: 8px; border-radius: var(--radius-input);
padding: 8px 14px; padding: 8px 14px;
margin-top: 14px; margin-top: 14px;
font-size: 0.86rem; font-size: 0.86rem;
@@ -960,7 +984,7 @@ button[disabled] {
gap: 8px; gap: 8px;
padding: 12px; padding: 12px;
border: 1px dashed var(--line); border: 1px dashed var(--line);
border-radius: 10px; border-radius: var(--radius-card);
margin-bottom: 16px; margin-bottom: 16px;
} }
.pb-canvas { .pb-canvas {
@@ -970,7 +994,7 @@ button[disabled] {
} }
.pb-block-card { .pb-block-card {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 10px; border-radius: var(--radius-card);
background: var(--panel-flat, transparent); background: var(--panel-flat, transparent);
} }
.pb-block-card.is-dragging { .pb-block-card.is-dragging {
@@ -1082,7 +1106,7 @@ button[disabled] {
border: 1px solid var(--accent); border: 1px solid var(--accent);
background: var(--blue); background: var(--blue);
color: var(--accent-bright); color: var(--accent-bright);
border-radius: 8px; border-radius: var(--radius-input);
padding: 8px 14px; padding: 8px 14px;
margin-bottom: 20px; margin-bottom: 20px;
font-size: 0.85rem; font-size: 0.85rem;

View File

@@ -0,0 +1,172 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { navItemVisibleTo, allowedPathsFor, isAllowedPath, firstDestinationFor } from '../src/lib/adminNav.js'
// Moderator confinement, derived from each row's `roles` (Phase 2 PR 8 —
// MODULE_SYSTEM.md §1.4). This replaced two hardcoded path lists that had
// drifted apart from each other, so the tests worth having are the ones that
// pin what a moderator may now see and reach, and the shape of the match.
// The real sidebar, trimmed to the rows that decide something here.
const NAV = [
{ items: [{ to: '/admin', label: 'Dashboard', end: true, roles: ['admin', 'editor', 'moderator'] }] },
{
title: 'Moderation',
items: [
{ to: '/admin/moderation', label: 'Moderation', roles: ['admin', 'moderator'] },
{ to: '/admin/moderation/appeals', label: 'Appeals', roles: ['admin', 'moderator'] },
{ to: '/admin/shard-ops', label: 'In-Game Ops', roles: ['admin', 'moderator'] },
{ to: '/admin/houses', label: 'Houses', roles: ['admin', 'moderator'] },
],
},
{
title: 'System',
items: [
{ to: '/admin/users', label: 'Users', roles: ['admin'] },
{ to: '/admin/settings', label: 'Settings', roles: ['admin'] },
],
},
{
items: [
{ to: '/admin/characters', label: 'My Characters' },
{ to: '/admin/account', label: 'Account' },
],
},
]
const visibleTo = (role) =>
NAV.flatMap((g) => g.items)
.filter((i) => navItemVisibleTo(i, role))
.map((i) => i.to)
test('a row with no roles is visible to every staff role', () => {
// Self-service: staff are a superset of players, so a moderator reaching their
// own characters is not a privilege, it is the thing every account has.
for (const role of ['admin', 'editor', 'moderator']) {
assert.equal(navItemVisibleTo({ to: '/admin/account' }, role), true)
}
})
test('a role not named on the row cannot see it', () => {
assert.equal(navItemVisibleTo({ to: '/admin/users', roles: ['admin'] }, 'moderator'), false)
assert.equal(navItemVisibleTo({ to: '/admin/users', roles: ['admin'] }, 'admin'), true)
// An unknown or absent role sees only the ungated rows.
assert.equal(navItemVisibleTo({ to: '/admin/users', roles: ['admin'] }, undefined), false)
assert.equal(navItemVisibleTo({ to: '/admin/account' }, undefined), true)
})
test('what a moderator sees is exactly the moderation section, plus self-service', () => {
// The two additions the derivation makes over the old MOD_PATHS list are
// Dashboard — whose roles have always named moderator, so the two lists
// disagreed — and My Characters. Both are already permitted server-side.
assert.deepEqual(visibleTo('moderator'), [
'/admin',
'/admin/moderation',
'/admin/moderation/appeals',
'/admin/shard-ops',
'/admin/houses',
'/admin/characters',
'/admin/account',
])
})
test('an admin still sees everything and an editor still sees nothing extra', () => {
assert.equal(visibleTo('admin').length, NAV.flatMap((g) => g.items).length)
assert.deepEqual(visibleTo('editor'), ['/admin', '/admin/characters', '/admin/account'])
})
test('a row with `end` matches exactly — the dashboard is not a prefix', () => {
// The bug this shape exists to prevent: treating `/admin` as a prefix would
// make every path in the admin area allowed for anyone who can see Dashboard.
const allowed = allowedPathsFor(NAV, 'moderator')
assert.equal(isAllowedPath('/admin', allowed), true)
assert.equal(isAllowedPath('/admin/users', allowed), false)
assert.equal(isAllowedPath('/admin/users/12', allowed), false)
})
test('every other row covers its own sub-routes', () => {
const allowed = allowedPathsFor(NAV, 'moderator')
assert.equal(isAllowedPath('/admin/moderation/appeals/12', allowed), true)
assert.equal(isAllowedPath('/admin/characters/0x4001', allowed), true)
})
test('a sibling path that merely shares a prefix is NOT covered', () => {
const allowed = allowedPathsFor(NAV, 'moderator')
// `/admin/houses-secret` starts with `/admin/houses` as a string; the match is
// on path segments, so it does not start with `/admin/houses/`.
assert.equal(isAllowedPath('/admin/houses-secret', allowed), false)
assert.equal(isAllowedPath('/admin/houses/42', allowed), true)
})
test('Houses is reachable, which is the defect the derivation fixed', () => {
// The redirect used to allow only /admin/moderation*, /admin/shard-ops* and
// /admin/account, while the sidebar showed Houses — so a moderator clicking a
// row in their own nav was bounced back to Moderation.
const allowed = allowedPathsFor(NAV, 'moderator')
assert.equal(isAllowedPath('/admin/houses', allowed), true)
})
test('a module row a moderator may see is reachable without core listing it', () => {
// The reason this is derived at all: core cannot hardcode a path it has never
// heard of, and a module row arrives with `roles` like any other.
const withModule = [
...NAV,
{ title: 'Shard', items: [{ to: '/admin/uo/shard-ops', label: 'Ops', roles: ['admin', 'moderator'], moduleId: 'uo' }] },
]
const allowed = allowedPathsFor(withModule, 'moderator')
assert.equal(isAllowedPath('/admin/uo/shard-ops', allowed), true)
assert.equal(isAllowedPath('/admin/uo/shard-ops/queue', allowed), true)
})
test('a nav that is not there does not throw', () => {
assert.deepEqual(allowedPathsFor(null, 'moderator'), [])
assert.equal(isAllowedPath('/admin', undefined), false)
})
// ── firstDestinationFor: where an area's index goes (Phase 3 slice 3) ────────
//
// `/player` used to be `PlayerCharacters`, a UO page. When the client half was
// extracted the portal had no index at all, and rather than pick a fixed page or
// invent a core landing screen, the index resolves to the first row this viewer
// can reach. The interesting properties are that it follows the ROLE and that it
// reads the base nav, not the merged one.
const PORTAL_NAV = [
{ to: '/account/appeals', label: 'Appeals' },
{ to: '/account', label: 'Account', end: true },
]
test('the index is the first row a viewer can actually reach', () => {
assert.equal(firstDestinationFor(PORTAL_NAV, 'player', '/account'), '/account/appeals')
})
test('a module row registered at the front becomes the index', () => {
// The behaviour that makes this a non-regression: with module-uo installed,
// Characters is the first row again and a player still lands on it.
const withModule = [{ to: '/player/uo/characters', label: 'Characters', moduleId: 'uo' }, ...PORTAL_NAV]
assert.equal(firstDestinationFor(withModule, 'player', '/account'), '/player/uo/characters')
})
test('a row this role cannot see is skipped, not landed on', () => {
const gated = [{ to: '/player/uo/staff', label: 'Staff', roles: ['admin'] }, ...PORTAL_NAV]
assert.equal(firstDestinationFor(gated, 'player', '/account'), '/account/appeals')
assert.equal(firstDestinationFor(gated, 'admin', '/account'), '/player/uo/staff')
})
test('an empty or all-gated nav falls back rather than resolving to nothing', () => {
assert.equal(firstDestinationFor([], 'player', '/account'), '/account')
assert.equal(firstDestinationFor(null, 'player', '/account'), '/account')
const allGated = [{ to: '/x', label: 'X', roles: ['admin'] }]
assert.equal(firstDestinationFor(allGated, 'player', '/account'), '/account')
})
test('it reads the grouped admin nav too, flattening in order', () => {
// Same function for both areas, which is the point: the admin index is a
// hardcoded Dashboard today, and if the two logged-in areas ever merge this is
// what answers for the result.
assert.equal(firstDestinationFor(NAV, 'moderator', '/admin/account'), '/admin')
// An editor cannot see Dashboard's neighbours in Moderation, so they land on
// the first row they can see wherever it is.
assert.equal(firstDestinationFor(NAV, 'editor', '/admin/account'), '/admin')
})

View File

@@ -128,10 +128,10 @@ test('wiki() with no options sends no query string at all', async () => {
assert.equal(calls[0].url, '/api/v1/public/wiki') assert.equal(calls[0].url, '/api/v1/public/wiki')
}) })
test('path params are URL-encoded (a token/city with unsafe characters is escaped)', async () => { test('path params are URL-encoded (a token with unsafe characters is escaped)', async () => {
willReply({ body: {} }) willReply({ body: {} })
await api.shard.governorHistory('Serpents Hold', 5) await api.getInvite('a b/c?d')
assert.match(calls[0].url, /\/governors\/Serpent%E2%80%99s%20Hold\/history\?limit=5/) assert.equal(calls[0].url, '/api/v1/auth/invite/a%20b%2Fc%3Fd')
}) })
test('DELETE self-service session revoke encodes the id and uses the DELETE method', async () => { test('DELETE self-service session revoke encodes the id and uses the DELETE method', async () => {

View File

@@ -0,0 +1,85 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { buildFeatureGate, OPEN_GATE } from '../src/modules/featureGate.js'
// The feature seam's decision logic (MODULE_SYSTEM.md §1.5, MODULE_API.md §3.3).
// Every branch here fails OPEN, and that is the property under test as much as
// the happy path: this is presentation, the server is the gate, and a UI mistake
// that hides a page from someone entitled to it is worse in every case than one
// that shows a link which then 403s.
const flags = (...names) => new Set(names)
test('a row with no feature is always visible', () => {
const gate = buildFeatureGate(new Map([['uo', flags()]]))
assert.equal(gate({ to: '/site/news' }), true)
})
test('a core row resolves against the owner id `core`', () => {
// Core's ten shard-gated rows carry no moduleId, and core registers its
// provider under `core` (main.jsx) precisely so they resolve without one.
const gate = buildFeatureGate(new Map([['core', flags('atlas')]]))
assert.equal(gate({ to: '/site/atlas', feature: 'atlas' }), true)
assert.equal(gate({ to: '/site/market', feature: 'market' }), false)
})
test('a module row resolves against ITS module, not another one', () => {
const gate = buildFeatureGate(
new Map([
['uo', flags('atlas')],
['rust', flags('market')],
]),
)
assert.equal(gate({ to: '/uo/atlas', feature: 'atlas', moduleId: 'uo' }), true)
// `market` is a flag the OTHER module grants. Resolution is by registration,
// so there is no string a module can write to borrow it.
assert.equal(gate({ to: '/uo/market', feature: 'market', moduleId: 'uo' }), false)
assert.equal(gate({ to: '/rust/market', feature: 'market', moduleId: 'rust' }), true)
})
test('no provider for the owner shows the row', () => {
// The no-module-installed case, and the reason the filter is a correct no-op
// on a bare core rather than a nav that renders nothing.
const gate = buildFeatureGate(new Map())
assert.equal(gate({ to: '/site/atlas', feature: 'atlas' }), true)
assert.equal(gate({ to: '/uo/atlas', feature: 'atlas', moduleId: 'uo' }), true)
})
test('a provider still loading shows the row', () => {
// useShardFlags returns null until its fetch lands. Blanking the nav on every
// page load and filling it in a moment later is the behaviour this avoids.
const gate = buildFeatureGate(new Map([['core', null]]))
assert.equal(gate({ to: '/site/atlas', feature: 'atlas' }), true)
})
test('a provider that returned something unusable shows the row', () => {
for (const bad of [undefined, 42, 'atlas', {}, []]) {
const gate = buildFeatureGate(new Map([['core', bad]]))
assert.equal(gate({ to: '/site/atlas', feature: 'atlas' }), true, `failed closed on ${JSON.stringify(bad)}`)
}
})
test('an array-backed provider is not silently treated as a Set', () => {
// `[].has` does not exist, so this is the unusable case above rather than a
// membership test that quietly always fails. Asserted so that a future
// "helpful" normalisation knows it changed a documented behaviour.
const gate = buildFeatureGate(new Map([['core', ['atlas']]]))
assert.equal(gate({ to: '/site/atlas', feature: 'atlas' }), true)
})
test('a missing map, or a junk row, shows rather than throws', () => {
assert.equal(buildFeatureGate(null)({ feature: 'atlas' }), true)
assert.equal(buildFeatureGate(new Map())(null), true)
assert.equal(buildFeatureGate(new Map())(undefined), true)
})
test('any Set-like satisfies a provider — core does not require a Set', () => {
const gate = buildFeatureGate(new Map([['uo', { has: (name) => name === 'ruleset' }]]))
assert.equal(gate({ feature: 'ruleset', moduleId: 'uo' }), true)
assert.equal(gate({ feature: 'champs', moduleId: 'uo' }), false)
})
test('the open gate is what a component outside the provider gets', () => {
assert.equal(OPEN_GATE({ feature: 'anything' }), true)
})

View File

@@ -0,0 +1,217 @@
import { test, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import { withModuleNav } from '../src/modules/nav.js'
import { registerNav, _reset } from '../src/modules/registry.js'
import { applyNavOverrides, buildPublicNav } from '../src/lib/navOverrides.js'
// The interleave of module nav rows into core's nav (MODULE_API.md §3.3, Phase 2
// PR 8). Tested against the real merge next door rather than in isolation,
// because the property that matters is a relationship between the two: a module
// row has to be indistinguishable from a core row to everything downstream, and
// the way to prove that is to run the downstream thing on it.
const PUBLIC = [
{ label: 'Home', to: '/', end: true },
{ label: 'News', to: '/site/news' },
{ label: 'About', to: '/site/about' },
]
const ADMIN = [
{ items: [{ to: '/admin', label: 'Dashboard', end: true, roles: ['admin', 'moderator'] }] },
{ title: 'Moderation', items: [{ to: '/admin/moderation', label: 'Moderation' }] },
{ title: 'System', items: [{ to: '/admin/users', label: 'Users' }, { to: '/admin/settings', label: 'Settings' }] },
{ items: [{ to: '/admin/account', label: 'Account' }] },
]
beforeEach(() => _reset())
test('with no module installed the base array is returned unchanged', () => {
// Identity, not a copy: this is what makes the useMemo in each layout honest,
// and what guarantees an instance with no modules renders what it renders now.
assert.equal(withModuleNav(PUBLIC, 'public'), PUBLIC)
assert.equal(withModuleNav(ADMIN, 'admin'), ADMIN)
})
test('a flat nav places a module row by the order it asked for', () => {
registerNav('uo', { area: 'public', items: [{ label: 'Atlas', to: '/uo/atlas', order: 1 }] })
assert.deepEqual(
withModuleNav(PUBLIC, 'public').map((i) => i.label),
['Home', 'Atlas', 'News', 'About'],
)
})
test('a flat row with no order appends rather than jumping to the front', () => {
// The 0-default trap: `order ?? 0` would put an unordered row first, which is
// the one place a module could take over the nav without asking for anything.
registerNav('uo', { area: 'public', items: [{ label: 'Atlas', to: '/uo/atlas' }] })
assert.deepEqual(
withModuleNav(PUBLIC, 'public').map((i) => i.label),
['Home', 'News', 'About', 'Atlas'],
)
})
test('an explicit order beats a core row that merely sits at that index', () => {
registerNav('uo', { area: 'public', items: [{ label: 'Atlas', to: '/uo/atlas', order: 2 }] })
const labels = withModuleNav(PUBLIC, 'public').map((i) => i.label)
assert.deepEqual(labels, ['Home', 'News', 'Atlas', 'About'])
})
test('an admin row lands INSIDE the core group it names', () => {
registerNav('uo', {
area: 'admin',
items: [
{ label: 'In-Game Ops', to: '/admin/uo/shard-ops', group: 'Moderation', order: 30 },
{ label: 'Shard', to: '/admin/uo/link', group: 'System', order: 0 },
],
})
const nav = withModuleNav(ADMIN, 'admin')
assert.deepEqual(nav.map((g) => g.title), [undefined, 'Moderation', 'System', undefined])
assert.deepEqual(nav[1].items.map((i) => i.label), ['Moderation', 'In-Game Ops'])
// order 0 puts it above both core rows, which is the whole point of the field.
assert.deepEqual(nav[2].items.map((i) => i.label), ['Shard', 'Users', 'Settings'])
})
test('an unknown group appends a new group instead of dropping the row', () => {
// A typo must cost a position, never a link.
registerNav('uo', { area: 'admin', items: [{ label: 'Atlas', to: '/admin/uo/atlas', group: 'Moderaton' }] })
const nav = withModuleNav(ADMIN, 'admin')
assert.equal(nav.length, ADMIN.length + 1)
assert.deepEqual(nav.at(-1), { title: 'Moderaton', items: [{ label: 'Atlas', to: '/admin/uo/atlas', group: 'Moderaton', moduleId: 'uo' }] })
})
test('an admin row with no group gets a trailing untitled group of its own', () => {
// NOT folded into one of core's untitled groups: those are Dashboard at the
// top and Account at the bottom, and a module page belongs beside neither.
registerNav('uo', { area: 'admin', items: [{ label: 'Atlas', to: '/admin/uo/atlas' }] })
const nav = withModuleNav(ADMIN, 'admin')
assert.equal(nav.length, ADMIN.length + 1)
assert.equal(nav.at(-1).title, undefined)
assert.deepEqual(nav.at(-1).items.map((i) => i.label), ['Atlas'])
assert.deepEqual(nav[0].items.map((i) => i.label), ['Dashboard'])
assert.deepEqual(nav[3].items.map((i) => i.label), ['Account'])
})
test('a row whose `to` collides with a core row is dropped, not rendered twice', () => {
// `to` is the key the override layer stores under and React renders by. Two
// rows sharing one would give an admin a single editor row that moves both.
const warnings = []
const warn = console.warn
console.warn = (msg) => warnings.push(msg)
try {
registerNav('uo', {
area: 'public',
items: [{ label: 'Not News', to: '/site/news' }, { label: 'Atlas', to: '/uo/atlas' }],
})
const nav = withModuleNav(PUBLIC, 'public')
assert.deepEqual(nav.map((i) => i.label), ['Home', 'News', 'About', 'Atlas'])
assert.equal(warnings.length, 1)
assert.match(warnings[0], /\/site\/news.*collides/)
} finally {
console.warn = warn
}
})
test('two modules cannot claim the same path either', () => {
const warn = console.warn
console.warn = () => {}
try {
registerNav('aa', { area: 'public', items: [{ label: 'First', to: '/shared' }] })
registerNav('zz', { area: 'public', items: [{ label: 'Second', to: '/shared' }] })
const labels = withModuleNav(PUBLIC, 'public').map((i) => i.label)
assert.deepEqual(labels, ['Home', 'News', 'About', 'First'])
} finally {
console.warn = warn
}
})
test('a module row carries its moduleId through, which is how the gate finds it', () => {
registerNav('uo', { area: 'public', items: [{ label: 'Atlas', to: '/uo/atlas', feature: 'atlas' }] })
const row = withModuleNav(PUBLIC, 'public').at(-1)
assert.equal(row.moduleId, 'uo')
assert.equal(row.feature, 'atlas')
})
test('a module row carries its icon through, and an override cannot touch it', () => {
// 1.3.0. Without an `icon` the six extracted UO rows would have been the only
// text-only entries in a sidebar where every other row has a glyph. Core
// renders whatever component the row carries and supplies no fallback — an
// invented one would be core making a presentation choice for content it knows
// nothing about.
const Glyph = () => null
registerNav('uo', {
area: 'admin',
items: [{ label: 'Shard', to: '/admin/uo/link', group: 'System', icon: Glyph }],
})
const merged = withModuleNav(ADMIN, 'admin')
const row = merged.find((g) => g.title === 'System').items.find((i) => i.to === '/admin/uo/link')
assert.equal(row.icon, Glyph)
// `icon` was already on navOverrides' list of fields an override may not
// touch, from long before a module could supply one. It still is.
const overridden = applyNavOverrides(merged, { '/admin/uo/link': { label: 'Renamed', icon: 'nope' } })
const after = overridden.find((g) => g.title === 'System').items.find((i) => i.to === '/admin/uo/link')
assert.equal(after.label, 'Renamed')
assert.equal(after.icon, Glyph)
})
test('a row with no icon simply has none, the same as a core row with none', () => {
registerNav('uo', { area: 'player', items: [{ label: 'Characters', to: '/player/uo/characters', order: 0 }] })
const row = withModuleNav([{ to: '/account', label: 'Account' }], 'player')[0]
assert.equal(row.to, '/player/uo/characters')
assert.equal(row.icon, undefined)
})
test('areas do not leak into one another', () => {
registerNav('uo', { area: 'admin', items: [{ label: 'Shard', to: '/admin/uo/link', group: 'System' }] })
assert.equal(withModuleNav(PUBLIC, 'public'), PUBLIC)
})
// ── The relationship that is the actual requirement ───────────────────────
test('an admin override applies to a module row exactly as to a core row', () => {
// The reason the interleave happens BEFORE the merge and not after: the merge
// drops any key its base array does not declare, so appending module rows
// afterwards would make every one of them unorderable, unrelabellable and
// unhideable — a visible regression the day the UO rows leave core.
registerNav('uo', { area: 'public', items: [{ label: 'Atlas', to: '/uo/atlas' }] })
const base = withModuleNav(PUBLIC, 'public')
const merged = applyNavOverrides(base, {
'/uo/atlas': { label: 'Bestiary', order: 0 },
'/site/news': { order: 3 },
})
assert.deepEqual(merged.map((i) => i.label), ['Bestiary', 'Home', 'About', 'News'])
})
test('an override can hide a module row, and the public tree can section it', () => {
registerNav('uo', { area: 'public', items: [{ label: 'Atlas', to: '/uo/atlas' }, { label: 'Market', to: '/uo/market' }] })
const base = withModuleNav(PUBLIC, 'public')
const hidden = buildPublicNav(base, { '/uo/atlas': { hidden: true } })
assert.equal(hidden.some((n) => n.to === '/uo/atlas'), false)
const sectioned = buildPublicNav(base, {
items: { '/uo/market': { section: 'sec_shard' } },
sections: [{ id: 'sec_shard', label: 'Shard', order: 0 }],
})
assert.equal(sectioned[0].kind, 'section')
assert.deepEqual(sectioned[0].items.map((i) => i.to), ['/uo/market'])
})
test('a module row can be moved between admin groups by an override', () => {
registerNav('uo', { area: 'admin', items: [{ label: 'Shard', to: '/admin/uo/link', group: 'System' }] })
const base = withModuleNav(ADMIN, 'admin')
const merged = applyNavOverrides(base, { '/admin/uo/link': { group: 'Moderation' } })
assert.deepEqual(merged[1].items.map((i) => i.to), ['/admin/moderation', '/admin/uo/link'])
assert.deepEqual(merged[2].items.map((i) => i.to), ['/admin/users', '/admin/settings'])
})
test('a group a module created is itself a legal override destination', () => {
// Falls out of building the destination set from the base nav it is handed —
// recorded because it is the kind of thing that would otherwise be discovered
// by an admin finding a section they cannot move anything into.
registerNav('uo', { area: 'admin', items: [{ label: 'Atlas', to: '/admin/uo/atlas', group: 'Shard' }] })
const base = withModuleNav(ADMIN, 'admin')
const merged = applyNavOverrides(base, { '/admin/users': { group: 'Shard' } })
assert.deepEqual(merged.at(-1).items.map((i) => i.to), ['/admin/uo/atlas', '/admin/users'])
})

View File

@@ -0,0 +1,188 @@
import { test, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import {
registry,
registerRoutes,
registerNav,
registerFeatureProvider,
routesFor,
navFor,
featureProviderFor,
featureProviders,
registeredIds,
_reset,
} from '../src/modules/registry.js'
import { MODULE_API_VERSION } from '../src/modules/version.js'
// The client-side module registry (docs/website/MODULE_API.md §3.3). Tested in
// isolation from React, like the nav-override merge next door, because the
// property worth proving has nothing to do with rendering: a module gets exactly
// the URL namespace core gave it, however it spells the paths it registers.
//
// window.__rg itself (modules/shared.js) is not tested here — it imports .jsx and
// there is no DOM in this runner. What it publishes is React, the router and
// core components: a wiring test would assert that an import statement imported
// something. Phase 1's spike proved the half that can actually fail, which is a
// real chunk resolving its externals against the global in a browser under an
// enforced CSP.
beforeEach(() => _reset())
test('a module route is namespaced under the module id', () => {
registerRoutes('uo', { public: [{ path: 'atlas', element: 'ATLAS' }] })
assert.deepEqual(
routesFor('public').map((r) => r.path),
['uo/atlas'],
)
})
test('a module cannot spell its way out of its namespace', () => {
// Whatever the module writes, the segment it lands under is core's to choose:
// leading slashes, several of them, a trailing one, or nothing at all.
registerRoutes('uo', {
public: [
{ path: '/atlas' },
{ path: '//atlas/creatures' },
{ path: 'atlas/' },
{ path: '' },
],
})
assert.deepEqual(
routesFor('public').map((r) => r.path),
['uo/atlas', 'uo/atlas/creatures', 'uo/atlas', 'uo'],
)
})
test('a path is namespaced, not sanitised — traversal stays a literal segment', () => {
// `..` is not stripped, and does not need to be: React Router matches path
// patterns literally, so `/uo/../admin` is a route nothing navigates to rather
// than a route that resolves somewhere else. Asserted so that a future
// "cleanup" that starts resolving these knows it changed a behaviour.
registerRoutes('uo', { public: [{ path: '../admin' }] })
assert.deepEqual(routesFor('public')[0].path, 'uo/../admin')
})
test('routes keep their gate and carry the owning module id', () => {
registerRoutes('uo', {
admin: [{ path: 'shard-ops', element: 'OPS', gate: { roles: ['admin', 'moderator'] } }],
})
const [route] = routesFor('admin')
assert.deepEqual(route.gate, { roles: ['admin', 'moderator'] })
assert.equal(route.moduleId, 'uo')
assert.equal(route.element, 'OPS')
})
test('the three areas are kept apart', () => {
registerRoutes('uo', {
public: [{ path: 'atlas' }],
admin: [{ path: 'link' }],
player: [{ path: 'chars' }],
})
assert.equal(routesFor('public').length, 1)
assert.equal(routesFor('admin').length, 1)
assert.equal(routesFor('player').length, 1)
// An area nobody registered is an empty list, never undefined: App.jsx maps
// over all three unconditionally.
_reset()
for (const area of ['public', 'admin', 'player']) assert.deepEqual(routesFor(area), [])
})
test('an unknown area throws rather than being dropped', () => {
// Loudly, because the alternative is a module whose pages simply never appear
// and no indication anywhere of why.
assert.throws(() => registerRoutes('uo', { publik: [{ path: 'atlas' }] }), /unknown area/)
assert.throws(() => registerNav('uo', { area: 'sidebar', items: [] }), /unknown area/)
assert.equal(registeredIds().length, 0)
})
test('nav items sort by order, and equal orders keep load order', () => {
registerNav('aa', { area: 'admin', items: [{ label: 'Second', to: '/a', order: 30 }] })
registerNav('zz', { area: 'admin', items: [{ label: 'Third', to: '/z', order: 30 }] })
registerNav('mm', { area: 'admin', items: [{ label: 'First', to: '/m', order: 10 }] })
assert.deepEqual(
navFor('admin').map((i) => i.label),
['First', 'Second', 'Third'],
)
})
test('a nav item with no order sorts after the ones that asked for a place', () => {
registerNav('uo', {
area: 'public',
items: [{ label: 'Unordered', to: '/u' }, { label: 'Early', to: '/e', order: 5 }],
})
assert.deepEqual(
navFor('public').map((i) => i.label),
['Early', 'Unordered'],
)
})
test('a feature provider is stored under its namespace, with its owner', () => {
const hook = () => ({ atlas: true })
registerFeatureProvider('uo', 'shard', hook)
assert.deepEqual(featureProviderFor('shard'), { id: 'uo', hook })
assert.equal(featureProviderFor('nothing'), undefined)
})
test('providers can be enumerated in registration order, with their owner', () => {
// Core's feature context has to CALL each of these, as a hook, in a fixed
// order — so it needs the list, and it needs the owner id to resolve a nav
// row whose `moduleId` says who it belongs to (modules/features.jsx).
const uo = () => null
const rust = () => null
registerFeatureProvider('uo', 'shard', uo)
registerFeatureProvider('rust', 'server', rust)
assert.deepEqual(featureProviders(), [
{ id: 'uo', namespace: 'shard', hook: uo },
{ id: 'rust', namespace: 'server', hook: rust },
])
})
test('enumerating providers is NOT part of the module-facing surface', () => {
// A module asks for a namespace it knows the name of; enumerating what
// everyone else registered is core's business, so `featureProviders` is a
// module export and not a member of window.__rg.registry.
assert.equal(registry.featureProviders, undefined)
assert.equal(typeof featureProviders, 'function')
})
test('every registration marks the module registered', () => {
registerRoutes('a', { public: [{ path: 'x' }] })
registerNav('b', { area: 'public', items: [] })
registerFeatureProvider('c', 'ns', () => {})
assert.deepEqual(registeredIds().sort(), ['a', 'b', 'c'])
})
test('the registry object handed to modules exposes the whole surface', () => {
// window.__rg.registry is the ONLY way a module reaches any of this, so a
// member missing from the object is a member that does not exist.
assert.deepEqual(Object.keys(registry).sort(), [
'featureProviderFor',
'navFor',
'registerExtension',
'registerFeatureProvider',
'registerNav',
'registerRoutes',
'registeredIds',
'routesFor',
])
})
test('the client and server halves declare the same MODULE_API_VERSION', () => {
// The value is duplicated because it has to be on window.__rg before the first
// module chunk evaluates, which is earlier than a fetch could answer. This is
// the test that pays for the copy: a bump that edits one file fails here
// instead of shipping a core whose two halves disagree about the contract they
// implement.
const here = path.dirname(fileURLToPath(import.meta.url))
const server = fs.readFileSync(
path.join(here, '..', '..', 'server', 'src', 'modules', 'version.js'),
'utf8',
)
const match = server.match(/MODULE_API_VERSION\s*=\s*'([^']+)'/)
assert.ok(match, 'server/src/modules/version.js no longer declares MODULE_API_VERSION as a literal')
assert.equal(MODULE_API_VERSION, match[1])
})

View File

@@ -0,0 +1,94 @@
import { test, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import {
registry,
declareSlot,
registerExtension,
extensionFor,
registeredIds,
_reset,
} from '../src/modules/registry.js'
// Client extension slots (docs/website/MODULE_API.md §3.7) — the client twin of
// the server's declareSlot/registerExtension.
//
// The registry half only. `<Slot>` itself renders, and there is no DOM in this
// runner, so what it does with what these functions return — including the error
// boundary — is proved by the §7.7 browser smoke instead. Everything below is a
// rule that can be stated without rendering anything, and every one of them can
// be got wrong in a way a browser check would not obviously catch.
beforeEach(() => _reset())
const Fake = () => null
const Other = () => null
test('an unfilled slot reads as nothing', () => {
// The guarantee core's layouts rest on: place a slot, install no module, and
// the page renders what it rendered before.
declareSlot('site.footer.status')
assert.equal(extensionFor('site.footer.status'), null)
})
test('an undeclared slot reads as nothing rather than throwing', () => {
// Reading is core's side and stays fail-safe: a typo in a layout costs that
// spot, not the page. Only WRITING is strict, which is the next test.
assert.equal(extensionFor('nope'), null)
})
test('a module fills a declared slot and core reads it back', () => {
declareSlot('admin.users.detail')
registerExtension('uo', 'admin.users.detail', Fake)
assert.equal(extensionFor('admin.users.detail'), Fake)
assert.deepEqual(registeredIds(), ['uo'])
})
test('filling an unknown slot throws, naming the slot', () => {
// This is the one place the client registry is NOT fail-open, and the reason
// is asymmetry of consequence: a dropped nav row costs a link the viewer can
// reach another way, a silently dropped extension is invisible to everyone
// including its author. Declaration structurally precedes filling (§3.1), so
// this can only ever be a typo or a version skew.
assert.throws(() => registerExtension('uo', 'site.footer.sttaus', Fake), /unknown extension slot "site\.footer\.sttaus"/)
})
test('a non-component fill throws', () => {
declareSlot('site.footer.status')
assert.throws(() => registerExtension('uo', 'site.footer.status', { render: true }), /is not a component/)
})
test('a second module cannot take a filled slot, and the first keeps it', () => {
// Matches the server's rule exactly (registries.js): first fill wins, second
// is an error. The second half of the assertion is the one that matters — a
// rejected fill must not have half-replaced the incumbent.
declareSlot('admin.users.detail')
registerExtension('uo', 'admin.users.detail', Fake)
assert.throws(() => registerExtension('other', 'admin.users.detail', Other), /already filled by "uo"/)
assert.equal(extensionFor('admin.users.detail'), Fake)
})
test('declaring a slot twice throws', () => {
// Core-side programming error: two owners for one position means whichever
// module registered first wins by file order.
declareSlot('site.footer.status')
assert.throws(() => declareSlot('site.footer.status'), /already declared/)
})
test('core fills a slot through the same seam a module uses', () => {
// The client twin of registries.registerCore(). Core is a registrant with an
// id like any other, which is what makes slice 3 a deletion: the module
// registers the same slot and core drops its line.
declareSlot('site.footer.status')
registerExtension('core', 'site.footer.status', Fake)
assert.deepEqual(registeredIds(), ['core'])
})
test('declareSlot and extensionFor are not on the module-facing registry', () => {
// Declaring is core's alone (§3.7), and reading who filled a slot is core's
// too — the same line featureProviders() draws. registerExtension IS on the
// object, because filling is the whole point.
assert.equal(registry.declareSlot, undefined)
assert.equal(registry.extensionFor, undefined)
assert.equal(typeof registry.registerExtension, 'function')
})

View File

@@ -0,0 +1,544 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
applyNavOverrides,
buildNavRows,
buildNavOverrides,
buildPublicNav,
pruneNav,
buildPublicNavOverrides,
} from '../src/lib/navOverrides.js'
// The nav-override merge (docs/website/THEMING_AND_NAV.md §7.1) — the one piece
// of this feature with real correctness risk, so it is tested in isolation from
// React. Two properties matter above all others:
//
// 1. No override, or a useless one, renders the coded nav untouched.
// 2. The override cannot add a route, cannot touch a role/feature gate, and
// cannot un-hide anything. It is presentation only.
const FLAT = [
{ label: 'Home', to: '/', end: true },
{ label: 'News', to: '/site/news' },
{ label: 'Wiki', to: '/wiki' },
{ label: 'Shard', to: '/site/shard', feature: 'status' },
]
const GROUPED = [
{ items: [{ to: '/admin', label: 'Dashboard', end: true, roles: ['admin', 'editor', 'moderator'] }] },
{
title: 'Content',
items: [
{ to: '/admin/posts', label: 'Posts', roles: ['admin', 'editor'] },
{ to: '/admin/wiki', label: 'Wiki', roles: ['admin', 'editor'] },
],
},
{
title: 'System',
items: [
{ to: '/admin/settings', label: 'Settings', roles: ['admin'] },
{ to: '/admin/users', label: 'Users', roles: ['admin'] },
],
},
]
const labels = (nav) => nav.map((i) => i.label)
const groupLabels = (nav) => nav.map((g) => [g.title ?? null, g.items.map((i) => i.label)])
// ── The untouched path ────────────────────────────────────────────────────
// Most instances will never set these keys. Absence must be a true no-op, and
// cheap: the same array reference back means no needless re-render either.
test('no override returns the base nav unchanged', () => {
for (const overrides of [null, undefined, '', 0, [], 'not an object']) {
assert.equal(applyNavOverrides(FLAT, overrides), FLAT)
}
})
test('an override with nothing usable in it returns the base nav unchanged', () => {
assert.equal(applyNavOverrides(FLAT, {}), FLAT)
// Every field here is unusable: unknown route, blank label, non-numeric order,
// hidden as a string rather than the boolean true.
assert.equal(
applyNavOverrides(FLAT, {
'/does/not/exist': { label: 'Ghost', hidden: true },
'/wiki': { label: ' ', order: 'first', hidden: 'yes' },
}),
FLAT,
)
})
// ── The security boundary ─────────────────────────────────────────────────
// The single most important negative case: the override layer must never be a
// way to introduce a route into a nav.
test('an unknown `to` is ignored, never added', () => {
const out = applyNavOverrides(FLAT, { '/admin/secret': { label: 'Secret', order: 0 } })
assert.equal(out.length, FLAT.length)
assert.ok(!out.some((i) => i.to === '/admin/secret'))
})
test('roles, feature, icon, end and to survive the merge verbatim', () => {
const out = applyNavOverrides(FLAT, {
'/site/shard': { label: 'Server Status', roles: ['player'], feature: null, to: '/evil' },
})
const shard = out.find((i) => i.to === '/site/shard')
assert.equal(shard.label, 'Server Status') // the one thing an override may set
assert.equal(shard.feature, 'status') // gate untouched
assert.equal(shard.roles, undefined) // and not invented
assert.ok(!out.some((i) => i.to === '/evil'))
})
test('hidden:false cannot un-hide anything — hiding is subtractive only', () => {
// The item is still present after the merge; whether it renders is decided by
// the caller's own role/feature filter, which this layer cannot reach.
const out = applyNavOverrides(GROUPED, { '/admin/settings': { hidden: false } })
assert.equal(out, GROUPED, 'a no-op override leaves the base nav alone')
})
// ── Flat navs: label, order, hidden ───────────────────────────────────────
test('label overrides only the labelled item', () => {
const out = applyNavOverrides(FLAT, { '/site/news': { label: 'Announcements' } })
assert.deepEqual(labels(out), ['Home', 'Announcements', 'Wiki', 'Shard'])
})
test('hidden drops the item', () => {
const out = applyNavOverrides(FLAT, { '/wiki': { hidden: true } })
assert.deepEqual(labels(out), ['Home', 'News', 'Shard'])
})
// An item the admin never reordered keeps its position in the coded array, so
// setting one order does not scramble the rest.
test('order moves one item and leaves the others in code order', () => {
const out = applyNavOverrides(FLAT, { '/wiki': { order: -1 } })
assert.deepEqual(labels(out), ['Wiki', 'Home', 'News', 'Shard'])
})
test('two items given the same order keep their code order (stable sort)', () => {
const out = applyNavOverrides(FLAT, { '/site/news': { order: 0 }, '/wiki': { order: 0 } })
// News before Wiki — the tie resolves to the coded order, not to insertion
// order in the settings JSON. Both precede Home, whose 0 is only its index.
assert.deepEqual(labels(out), ['News', 'Wiki', 'Home', 'Shard'])
})
// An explicit order and an untouched item's index share one number line, so
// they can collide. "Put this first" has to actually mean first.
test('an explicit order beats an untouched item that merely sits at that index', () => {
const out = applyNavOverrides(FLAT, { '/wiki': { order: 0 } })
assert.deepEqual(labels(out), ['Wiki', 'Home', 'News', 'Shard'])
})
test('the merge does not mutate the base nav', () => {
const before = JSON.stringify(FLAT)
applyNavOverrides(FLAT, { '/wiki': { label: 'Library', order: 0, hidden: false } })
assert.equal(JSON.stringify(FLAT), before)
})
test('no internal sort key leaks into the returned items', () => {
const out = applyNavOverrides(FLAT, { '/wiki': { order: 1 } })
for (const item of out) assert.ok(!('__order' in item), 'sort key must not be rendered')
})
// ── Grouped (admin) navs ──────────────────────────────────────────────────
test('label and order apply within a group', () => {
const out = applyNavOverrides(GROUPED, {
'/admin/wiki': { label: 'Knowledge Base', order: 0 },
})
assert.deepEqual(groupLabels(out), [
[null, ['Dashboard']],
['Content', ['Knowledge Base', 'Posts']],
['System', ['Settings', 'Users']],
])
})
test('group moves an item into another existing section', () => {
const out = applyNavOverrides(GROUPED, { '/admin/users': { group: 'Content' } })
assert.deepEqual(groupLabels(out), [
[null, ['Dashboard']],
['Content', ['Posts', 'Wiki', 'Users']],
['System', ['Settings']],
])
})
// A group that does not exist must not conjure a header. Groups are chosen from
// a dropdown of existing titles in the editor; this is the stale-row guard.
test('a group that is not an existing title is ignored', () => {
const out = applyNavOverrides(GROUPED, { '/admin/users': { group: 'Danger Zone' } })
assert.deepEqual(groupLabels(out), [
[null, ['Dashboard']],
['Content', ['Posts', 'Wiki']],
['System', ['Settings', 'Users']],
])
})
test('a moved item can be ordered in its new group', () => {
const out = applyNavOverrides(GROUPED, { '/admin/users': { group: 'Content', order: -1 } })
assert.deepEqual(groupLabels(out)[1], ['Content', ['Users', 'Posts', 'Wiki']])
})
test('hiding every item in a group leaves no orphaned header', () => {
const out = applyNavOverrides(GROUPED, {
'/admin/settings': { hidden: true },
'/admin/users': { hidden: true },
})
assert.deepEqual(groupLabels(out), [
[null, ['Dashboard']],
['Content', ['Posts', 'Wiki']],
])
})
test('group ordering itself is not overridable — sections stay in code order', () => {
const out = applyNavOverrides(GROUPED, { '/admin/settings': { order: -99 } })
assert.deepEqual(
out.map((g) => g.title ?? null),
[null, 'Content', 'System'],
)
})
// ── Degenerate input ──────────────────────────────────────────────────────
test('a non-array base nav yields an empty nav rather than throwing', () => {
assert.deepEqual(applyNavOverrides(null, { '/': { hidden: true } }), [])
assert.deepEqual(applyNavOverrides(undefined, null), [])
})
test('an empty base nav stays empty', () => {
assert.deepEqual(applyNavOverrides([], { '/': { label: 'Home' } }), [])
})
// ── The editor's round trip (phase 7) ─────────────────────────────────────
//
// buildNavRows and buildNavOverrides are inverse, and the property that matters
// is that the editor and the site agree: the rows an admin drags come out of the
// same merge the layouts render, hidden ones included.
const rowLabels = (groups) => groups.map((g) => [g.title, g.items.map((i) => i.label)])
test('rows with no override are the coded nav, in code order', () => {
const rows = buildNavRows(FLAT, null)
assert.deepEqual(rowLabels(rows), [[null, ['Home', 'News', 'Wiki', 'Shard']]])
assert.equal(rows[0].items.every((i) => i.hidden === false), true)
})
test('a flat nav becomes one untitled group, so one editor handles both shapes', () => {
assert.equal(buildNavRows(FLAT, null).length, 1)
assert.equal(buildNavRows(GROUPED, null).length, 3)
})
test('rows keep hidden items, in place and marked — the site drops them', () => {
const overrides = { '/site/news': { hidden: true } }
// The layout must not render it...
assert.deepEqual(labels(applyNavOverrides(FLAT, overrides)), ['Home', 'Wiki', 'Shard'])
// ...while the editor must, or there is no way to un-hide it.
const rows = buildNavRows(FLAT, overrides)[0].items
assert.deepEqual(rows.map((i) => i.label), ['Home', 'News', 'Wiki', 'Shard'])
assert.equal(rows[1].hidden, true)
assert.equal(rows[0].hidden, false)
})
test('rows carry the coded label alongside the overridden one', () => {
const rows = buildNavRows(FLAT, { '/site/news': { label: 'Announcements' } })[0].items
assert.equal(rows[1].label, 'Announcements')
assert.equal(rows[1].defaultLabel, 'News')
})
test('rows show the same order the site renders', () => {
const overrides = { '/wiki': { order: 0 }, '/': { order: 1 } }
assert.deepEqual(labels(applyNavOverrides(FLAT, overrides)), ['Wiki', 'Home', 'News', 'Shard'])
assert.deepEqual(rowLabels(buildNavRows(FLAT, overrides)), [[null, ['Wiki', 'Home', 'News', 'Shard']]])
})
test('rows keep an emptied group so something can be moved back into it', () => {
// applyNavOverrides drops a group whose every item is hidden; the editor must
// still show the header, or the section is unreachable forever.
const overrides = { '/admin/posts': { hidden: true }, '/admin/wiki': { hidden: true } }
assert.equal(applyNavOverrides(GROUPED, overrides).some((g) => g.title === 'Content'), false)
assert.equal(buildNavRows(GROUPED, overrides).some((g) => g.title === 'Content'), true)
})
test('an untouched editor saves nothing at all', () => {
// Opening the screen and pressing Save must not pin the position of every
// item — the caller deletes the row when this comes back empty.
assert.deepEqual(buildNavOverrides(buildNavRows(FLAT, null), FLAT), {})
assert.deepEqual(buildNavOverrides(buildNavRows(GROUPED, null), GROUPED), {})
})
test('a rename alone writes a label and no orders', () => {
const groups = buildNavRows(FLAT, null)
groups[0].items[1].label = 'Announcements'
assert.deepEqual(buildNavOverrides(groups, FLAT), { '/site/news': { label: 'Announcements' } })
})
test('a label typed back to the coded one is not stored as an override', () => {
const groups = buildNavRows(FLAT, { '/site/news': { label: 'Announcements' } })
groups[0].items[1].label = 'News'
assert.deepEqual(buildNavOverrides(groups, FLAT), {})
// Whitespace-only reads as "use the default" too.
groups[0].items[1].label = ' '
assert.deepEqual(buildNavOverrides(groups, FLAT), {})
})
test('hiding alone writes hidden and no orders', () => {
const groups = buildNavRows(FLAT, null)
groups[0].items[3].hidden = true
assert.deepEqual(buildNavOverrides(groups, FLAT), { '/site/shard': { hidden: true } })
})
test('reordering writes an order for every row in the list', () => {
// §7.1: explicit and implicit sort keys share one number line, so a partial
// set of orders is the stale-row case rather than something the editor makes.
const groups = buildNavRows(FLAT, null)
const [home] = groups[0].items.splice(0, 1)
groups[0].items.push(home)
assert.deepEqual(buildNavOverrides(groups, FLAT), {
'/site/news': { order: 0 },
'/wiki': { order: 1 },
'/site/shard': { order: 2 },
'/': { order: 3 },
})
})
test('the round trip is stable: save, reload, save again yields the same thing', () => {
const groups = buildNavRows(FLAT, null)
groups[0].items.reverse()
groups[0].items[0].label = 'The Shard'
const first = buildNavOverrides(groups, FLAT)
const second = buildNavOverrides(buildNavRows(FLAT, first), FLAT)
assert.deepEqual(second, first)
// And it renders what the editor showed.
assert.deepEqual(labels(applyNavOverrides(FLAT, first)), ['The Shard', 'Wiki', 'News', 'Home'])
})
test('moving an item to another section writes group, and moving it back clears it', () => {
const groups = buildNavRows(GROUPED, null)
const [posts] = groups[1].items.splice(0, 1)
groups[2].items.push(posts)
const saved = buildNavOverrides(groups, GROUPED)
assert.equal(saved['/admin/posts'].group, 'System')
assert.deepEqual(groupLabels(applyNavOverrides(GROUPED, saved)), [
[null, ['Dashboard']],
['Content', ['Wiki']],
['System', ['Settings', 'Users', 'Posts']],
])
const back = buildNavRows(GROUPED, saved)
const [moved] = back[2].items.splice(2, 1)
back[1].items.unshift(moved)
assert.equal(buildNavOverrides(back, GROUPED)['/admin/posts'], undefined)
})
test('an override for an item outside this admins palette survives a save', () => {
// §8.1 filters the editor to what the editing admin can themselves see. An
// item filtered out has no row, and must not be quietly reset by their save.
const visible = buildNavRows(FLAT, { '/site/shard': { hidden: true } }).map((g) => ({
...g,
items: g.items.filter((i) => !i.feature),
}))
const stored = { '/site/shard': { hidden: true }, '/site/news': { label: 'Old' } }
const out = buildNavOverrides(visible, FLAT, stored)
assert.deepEqual(out['/site/shard'], { hidden: true })
// The rows they *could* see still win over what was stored.
assert.equal(out['/site/news'], undefined)
})
test('a stored entry for a route the code no longer declares is dropped on save', () => {
const groups = buildNavRows(FLAT, null)
const out = buildNavOverrides(groups, FLAT, { '/site/gone': { label: 'Ghost' } })
assert.deepEqual(out, {})
})
test('degenerate input yields an empty result rather than throwing', () => {
assert.deepEqual(buildNavRows(null, {}), [])
assert.deepEqual(buildNavRows([], {}), [])
assert.deepEqual(buildNavOverrides(null, FLAT), {})
assert.deepEqual(buildNavOverrides([], null), {})
})
// ── The public header: sections and added links (phase 10) ────────────────
//
// The one nav an admin can restructure rather than only reorder. The invariant
// that has to survive is §7's, in its narrower form: a CODED entry still cannot
// have its `to` or `feature` touched, and everything that can name an arbitrary
// path lives in `links`, where the path rule applies.
const PUB = [
{ label: 'Home', to: '/', end: true },
{ label: 'News', to: '/site/news' },
{ label: 'Champions', to: '/site/champs', feature: 'champs' },
{ label: 'Guilds', to: '/site/guilds', feature: 'guilds' },
{ label: 'About', to: '/site/about' },
]
const shape = (tree) =>
tree.map((n) => (n.kind === 'section' ? { [n.label]: n.items.map((i) => i.label) } : n.label))
test('no override yields the coded header, in code order', () => {
assert.deepEqual(shape(buildPublicNav(PUB, null)), ['Home', 'News', 'Champions', 'Guilds', 'About'])
assert.deepEqual(shape(buildPublicNav(PUB, {})), ['Home', 'News', 'Champions', 'Guilds', 'About'])
})
test('a phase 6-8 bare map still reads as the items map', () => {
// Nothing has shipped, but a row written during review must not become
// unreadable just because the wrapper arrived.
assert.deepEqual(shape(buildPublicNav(PUB, { '/site/news': { label: 'Announcements' } })), [
'Home',
'Announcements',
'Champions',
'Guilds',
'About',
])
})
const SECTIONED = {
items: { '/site/champs': { section: 'sec_aaaa', order: 0 }, '/site/guilds': { section: 'sec_aaaa', order: 1 } },
sections: [{ id: 'sec_aaaa', label: 'The World', order: 2 }],
links: [{ id: 'lnk_bbbb', label: 'Guide', to: '/wiki/new-player-guide', section: 'sec_aaaa', order: 2 }],
}
test('a section collects its members and sits in the top-level order', () => {
assert.deepEqual(shape(buildPublicNav(PUB, SECTIONED)), [
'Home',
'News',
{ 'The World': ['Champions', 'Guilds', 'Guide'] },
'About',
])
})
test('an added link is kept apart from the coded items', () => {
const tree = buildPublicNav(PUB, SECTIONED)
const link = tree.find((n) => n.kind === 'section').items.find((i) => i.kind === 'link')
assert.equal(link.to, '/wiki/new-player-guide')
assert.equal(link.id, 'lnk_bbbb')
// It carries no gate of its own — that is the documented contract, and the
// page behind it is what actually enforces access.
assert.equal(link.feature, undefined)
assert.equal(link.roles, undefined)
})
test('an off-origin link is dropped rather than rendered', () => {
for (const to of ['https://evil.example', '//evil.example/x', 'javascript:alert(1)', '/x y', '/a"b']) {
const tree = buildPublicNav(PUB, { items: {}, links: [{ id: 'lnk_bbbb', label: 'Bad', to }] })
assert.equal(
tree.some((n) => n.kind === 'link'),
false,
`${to} should be dropped`,
)
}
})
test('an item naming a section that does not exist stays at the top level', () => {
const tree = buildPublicNav(PUB, { items: { '/site/champs': { section: 'sec_gone' } } })
assert.deepEqual(shape(tree), ['Home', 'News', 'Champions', 'Guilds', 'About'])
})
test('an override still cannot introduce a coded route', () => {
const tree = buildPublicNav(PUB, { items: { '/site/secret': { label: 'Secret' } } })
assert.equal(
tree.some((n) => n.to === '/site/secret'),
false,
)
})
test('hidden entries are dropped for the site and kept for the editor', () => {
const overrides = { items: { '/site/news': { hidden: true } } }
assert.equal(shape(buildPublicNav(PUB, overrides)).includes('News'), false)
const rows = buildPublicNav(PUB, overrides, { keepHidden: true })
assert.equal(rows.find((n) => n.to === '/site/news').hidden, true)
})
// ── pruneNav: the empty dropdown ──────────────────────────────────────────
test('a section keeps the entries the viewer may see', () => {
const tree = buildPublicNav(PUB, SECTIONED)
const out = pruneNav(tree, (i) => i.feature !== 'guilds')
assert.deepEqual(shape(out), ['Home', 'News', { 'The World': ['Champions', 'Guide'] }, 'About'])
})
test('a section whose every entry is gated out does not render at all', () => {
// The case that matters: a dropdown that opens onto nothing is worse than no
// dropdown, and shard visibility can empty one at any time.
const overrides = {
items: { '/site/champs': { section: 'sec_aaaa' }, '/site/guilds': { section: 'sec_aaaa' } },
sections: [{ id: 'sec_aaaa', label: 'The World' }],
}
const tree = buildPublicNav(PUB, overrides)
assert.deepEqual(shape(pruneNav(tree, () => true)), [
'Home',
'News',
'About',
{ 'The World': ['Champions', 'Guilds'] },
])
assert.deepEqual(shape(pruneNav(tree, (i) => !i.feature)), ['Home', 'News', 'About'])
})
test('an added link is never pruned — it carries no gate', () => {
const tree = buildPublicNav(PUB, { items: {}, links: [{ id: 'lnk_bbbb', label: 'Guide', to: '/wiki/g' }] })
assert.equal(
pruneNav(tree, () => false).some((n) => n.kind === 'link'),
true,
)
})
// ── The editor round trip ─────────────────────────────────────────────────
test('an untouched public editor saves nothing', () => {
assert.deepEqual(buildPublicNavOverrides(buildPublicNav(PUB, null, { keepHidden: true }), PUB), {})
})
test('a nav with no sections still stores the plain items map', () => {
// Adding this feature changed nothing for a nav that does not use it.
const tree = buildPublicNav(PUB, null, { keepHidden: true })
tree[1].label = 'Announcements'
const out = buildPublicNavOverrides(tree, PUB)
assert.deepEqual(out, { '/site/news': { label: 'Announcements' } })
assert.equal(out.items, undefined)
})
test('the sectioned round trip is stable and renders what the editor showed', () => {
const tree = buildPublicNav(PUB, SECTIONED, { keepHidden: true })
const first = buildPublicNavOverrides(tree, PUB)
const second = buildPublicNavOverrides(buildPublicNav(PUB, first, { keepHidden: true }), PUB)
assert.deepEqual(second, first)
assert.deepEqual(shape(buildPublicNav(PUB, first)), [
'Home',
'News',
{ 'The World': ['Champions', 'Guilds', 'Guide'] },
'About',
])
})
test('deleting a section returns its entries to the top level, never deletes them', () => {
// The one destructive act this screen could commit, so it is locked here.
const tree = buildPublicNav(PUB, SECTIONED, { keepHidden: true })
const section = tree.find((n) => n.kind === 'section')
const flattened = [...tree.filter((n) => n.kind !== 'section'), ...section.items]
const out = buildPublicNavOverrides(flattened, PUB)
const rendered = buildPublicNav(PUB, out)
assert.equal(
rendered.some((n) => n.kind === 'section'),
false,
)
assert.deepEqual(shape(rendered), ['Home', 'News', 'About', 'Champions', 'Guilds', 'Guide'])
})
test('an override for a feature-gated item outside the palette survives a save', () => {
// §8.1 filters the editor to what this admin can see. The rows come from their
// palette, but membership is judged against the FULL coded nav — otherwise a
// row a shard feature hid from them is indistinguishable from a deleted route,
// and their save would silently reset it.
const palette = PUB.filter((i) => i.feature !== 'champs')
// The editor was opened on a nav that only hides champs — which their palette
// does not show them. `stored` additionally carries a label for a row they CAN
// see, and which they have since reset.
const tree = buildPublicNav(palette, { items: { '/site/champs': { hidden: true } } }, { keepHidden: true })
const stored = { items: { '/site/champs': { hidden: true }, '/site/news': { label: 'Old' } } }
const out = buildPublicNavOverrides(tree, PUB, stored)
assert.deepEqual(out['/site/champs'], { hidden: true }, 'carried: they could not see it')
assert.equal(out['/site/news'], undefined, 'not carried: their row is the authority for what they can see')
})
test('a stored entry for a route the code no longer declares is dropped on save', () => {
const tree = buildPublicNav(PUB, null, { keepHidden: true })
assert.deepEqual(buildPublicNavOverrides(tree, PUB, { items: { '/site/gone': { label: 'Ghost' } } }), {})
})

View File

@@ -1,60 +0,0 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { bucketize, BUCKETS } from '../src/data/regionBuckets.js'
// Unit-test the presence.online region roll-up for the "Players Online" widget.
// The load-bearing invariant: the bucket counts ALWAYS reconcile to the true
// total — anything unmatched lands in Wilderness — so the widget can never show
// a sum that disagrees with the headline online count.
test('bucketize groups named regions into their buckets', () => {
const { rows, total } = bucketize({
'Britain': 4,
'Moonglow': 2,
'Despise': 3,
'Green Acres House 12': 1, // not a town/dungeon name → Housing
})
const byId = Object.fromEntries(rows.map((r) => [r.id, r.count]))
assert.equal(byId.britain, 4)
assert.equal(byId.towns, 2)
assert.equal(byId.dungeons, 3)
assert.equal(byId.housing, 1)
assert.equal(total, 10)
})
test('first match wins by BUCKETS order: a town-named house region counts as Towns, not Housing', () => {
// The towns regex is ^-anchored and towns is checked BEFORE housing, so a house
// region whose name starts with a town name is bucketed as Towns. Pinning this
// documents the ordering dependency for anyone retuning BUCKETS.
const { rows } = bucketize({ 'Trinsic House 12': 1 })
const byId = Object.fromEntries(rows.map((r) => [r.id, r.count]))
assert.equal(byId.towns, 1)
assert.equal(byId.housing, undefined) // empty bucket dropped
})
test('an unmatched region falls through to Wilderness so counts always reconcile', () => {
const { rows, total } = bucketize({ 'Some Unnamed Field': 5, 'Wilderness': 2 })
const wilderness = rows.find((r) => r.id === 'wilderness')
assert.equal(wilderness.count, 7)
assert.equal(total, 7)
// The reconciliation guarantee: the buckets sum to the total, exactly.
assert.equal(rows.reduce((s, r) => s + r.count, 0), total)
})
test('bucketize returns rows in BUCKETS order and drops empty buckets', () => {
const { rows } = bucketize({ 'Despise': 1, 'Britain': 1 })
assert.deepEqual(rows.map((r) => r.id), ['britain', 'dungeons']) // BUCKETS order, no empty towns/housing/wilderness
})
test('bucketize coerces non-numeric counts and tolerates empty/nullish input', () => {
assert.deepEqual(bucketize({}), { rows: [], total: 0 })
assert.deepEqual(bucketize(), { rows: [], total: 0 })
const { total } = bucketize({ 'Britain': '3', 'Minoc': 'oops' })
assert.equal(total, 3) // '3' → 3, 'oops' → 0
})
test('the last bucket is the catch-all (its match accepts anything)', () => {
const last = BUCKETS[BUCKETS.length - 1]
assert.equal(last.id, 'wilderness')
assert.equal(last.match('literally anything'), true)
})

View File

@@ -0,0 +1,28 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { parseJsonSetting } from '../src/lib/settingsJson.js'
// The client counterpart to the server's parseJsonSetting. The property that
// matters is the fail-safe one: anything unusable reads as **absent**, so the
// consumer falls back to its coded default rather than rendering an error or a
// half-applied object (THEMING_AND_NAV.md §4.4).
test('absent, empty and malformed values read as absent', () => {
for (const bad of [undefined, null, '', '{', 'not json', 4, {}, []]) {
assert.equal(parseJsonSetting(bad), null, `${JSON.stringify(bad)} should read as absent`)
}
})
test('valid JSON that is not a plain object reads as absent', () => {
// A stored `null`, number, string or array is as unusable to every consumer of
// these keys as a syntax error is.
for (const bad of ['null', '4', '"x"', '[]', '[{"to":"/"}]', 'true']) {
assert.equal(parseJsonSetting(bad), null, `${bad} should read as absent`)
}
})
test('a well-formed object is returned as parsed', () => {
assert.deepEqual(parseJsonSetting('{"/site/news":{"order":2}}'), { '/site/news': { order: 2 } })
assert.deepEqual(parseJsonSetting('{}'), {})
})

View File

@@ -1,74 +0,0 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { describe, categoryOf, kindLabel, CATEGORIES } from '../src/lib/shardEvents.js'
// Unit-test the shared shard-event formatter — the single place that decides how
// each event kind reads and which filter category it belongs to. These strings
// are user-facing on the public Shard page, the Activity feed, and the admin
// live feed, so a regression here is visible everywhere at once.
// ── describe(): works on both stored (.payload) and live (top-level) frames ──
test('describe reads fields from .payload when present, else the top level', () => {
const stored = { kind: 'quest.complete', payload: { who: { name: 'Ada' }, quest: 'The Cavern' } }
const live = { kind: 'quest.complete', who: { name: 'Ada' }, quest: 'The Cavern' }
assert.equal(describe(stored), 'Ada completed “The Cavern”')
assert.equal(describe(live), 'Ada completed “The Cavern”')
})
test('describe resolves an actor from name → acct → "Someone"', () => {
assert.equal(describe({ kind: 'mob.login', who: { name: 'Bob' } }), 'Bob entered the world')
assert.equal(describe({ kind: 'mob.login', who: { acct: 'acct7' } }), 'acct7 entered the world')
assert.equal(describe({ kind: 'mob.login', who: null }), 'Someone entered the world')
assert.equal(describe({ kind: 'mob.login', who: 'RawString' }), 'RawString entered the world')
})
test('describe pluralizes a vendor sale only when amount > 1 and formats the price', () => {
assert.equal(describe({ kind: 'vendor.sale', itemType: 'Katana', amount: 1, price: 1200 }), 'Katana sold for 1,200gp')
assert.equal(describe({ kind: 'vendor.sale', itemType: 'Arrow', amount: 40, price: 80 }), 'Arrow ×40 sold for 80gp')
})
test('describe includes the killer only when present (optional clause)', () => {
assert.equal(describe({ kind: 'player.death', who: { name: 'Ada' } }), 'Ada was slain')
assert.equal(
describe({ kind: 'player.death', who: { name: 'Ada' }, killer: { name: 'Orc' } }),
'Ada was slain by Orc',
)
})
test('describe champ.update branches on status and boss state', () => {
assert.equal(describe({ kind: 'champ.update', name: 'Rikktor', status: 'active', bossUp: true }), 'Rikktor: boss is up')
assert.equal(
describe({ kind: 'champ.update', name: 'Rikktor', status: 'active', level: 3 }),
'Rikktor is active — level 3',
)
assert.equal(describe({ kind: 'champ.update', name: 'Rikktor', status: 'cooldown' }), 'Rikktor is on cooldown')
})
test('describe falls back to the raw kind for an unknown event', () => {
assert.equal(describe({ kind: 'some.future.kind' }), 'some.future.kind')
})
// ── categoryOf(): membership + catch-all ────────────────────────────────
test('categoryOf groups kinds per the CATEGORIES table, and unknowns are "other"', () => {
assert.equal(categoryOf('player.death'), 'pvp')
assert.equal(categoryOf('skill.gain'), 'progress')
assert.equal(categoryOf('house.decay'), 'world')
assert.equal(categoryOf('vendor.sale'), 'other') // deliberately not a public category
assert.equal(categoryOf('totally.unknown'), 'other')
})
test('every kind listed in CATEGORIES maps back to that category (table stays consistent)', () => {
for (const cat of CATEGORIES) {
if (!cat.kinds) continue
for (const kind of cat.kinds) {
assert.equal(categoryOf(kind), cat.id, `${kind} should be in ${cat.id}`)
}
}
})
// ── kindLabel(): badge text ─────────────────────────────────────────────
test('kindLabel turns dots/underscores into spaces and tolerates empty input', () => {
assert.equal(kindLabel('player.death'), 'player death')
assert.equal(kindLabel('account.login.attempt'), 'account login attempt')
assert.equal(kindLabel(null), '')
})

View File

@@ -0,0 +1,100 @@
// applyThemeTokens — writing the server-resolved theme onto the document, and
// (the part with real logic) taking back exactly what it wrote last time.
//
// Pure module, exercised against a fake CSSStyleDeclaration: node --test has no
// DOM, and the function only ever needs setProperty/removeProperty.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { applyThemeTokens } from '../src/lib/themeVars.js'
// Minimal stand-in for element.style, plus a log of the calls so a test can
// assert that a property was *removed* rather than merely absent.
function fakeStyle() {
const props = new Map()
const removed = []
return {
props,
removed,
setProperty: (name, value) => props.set(name, value),
removeProperty: (name) => {
props.delete(name)
removed.push(name)
},
get: (name) => props.get(name),
}
}
test('writes each token and reports the keys it applied', () => {
const style = fakeStyle()
const applied = applyThemeTokens(style, { '--accent': '#c9973f', '--bg': '#1a120b' })
assert.equal(style.get('--accent'), '#c9973f')
assert.equal(style.get('--bg'), '#1a120b')
assert.deepEqual(applied.sort(), ['--accent', '--bg'])
})
// The untouched-instance case: no theme block means the stylesheet's :root
// stands and nothing is written at all.
test('no theme writes nothing', () => {
for (const empty of [null, undefined, {}]) {
const style = fakeStyle()
const applied = applyThemeTokens(style, empty)
assert.equal(style.props.size, 0)
assert.deepEqual(applied, [])
}
})
test('removes a token that is no longer in the theme', () => {
const style = fakeStyle()
const first = applyThemeTokens(style, { '--accent': '#c9973f', '--bg': '#1a120b' })
const second = applyThemeTokens(style, { '--accent': '#c9973f' }, first)
assert.equal(style.get('--accent'), '#c9973f')
assert.equal(style.get('--bg'), undefined)
assert.deepEqual(style.removed, ['--bg'])
assert.deepEqual(second, ['--accent'])
})
// "Reset to defaults" — the case that would look broken without the removal
// half: the payload stops mentioning the variables, and the inline values have
// to come off for :root to show through again.
test('resetting to no theme clears everything previously applied', () => {
const style = fakeStyle()
const first = applyThemeTokens(style, { '--accent': '#c9973f', '--radius-card': '2px' })
const second = applyThemeTokens(style, null, first)
assert.equal(style.props.size, 0)
assert.deepEqual(style.removed.sort(), ['--accent', '--radius-card'])
assert.deepEqual(second, [])
})
// Only ever clears its own keys. SiteContext writes --accent itself from
// brand.accent, and a future feature may write others; those are not ours.
test('never removes a property it did not apply', () => {
const style = fakeStyle()
style.setProperty('--accent', '#ff0000') // someone else's write
applyThemeTokens(style, { '--bg': '#000000' }, [])
assert.equal(style.get('--accent'), '#ff0000')
assert.deepEqual(style.removed, [])
})
test('ignores anything that is not a custom property', () => {
const style = fakeStyle()
const applied = applyThemeTokens(style, { background: 'url(http://evil.example/x)', '--bg': '#000000' })
assert.equal(style.get('background'), undefined)
assert.deepEqual(applied, ['--bg'])
})
test('ignores non-string and empty values', () => {
const style = fakeStyle()
const applied = applyThemeTokens(style, { '--a': 4, '--b': null, '--c': '', '--d': '#fff' })
assert.deepEqual(applied, ['--d'])
})
// A stale key list must not survive a call that could not write: the next call
// still has to know what is actually on the element.
test('a token dropped as invalid is removed if it was applied before', () => {
const style = fakeStyle()
const first = applyThemeTokens(style, { '--bg': '#000000' })
const second = applyThemeTokens(style, { '--bg': '' }, first)
assert.equal(style.get('--bg'), undefined)
assert.deepEqual(second, [])
})

View File

@@ -35,6 +35,10 @@ services:
DB_HOST: db DB_HOST: db
UPLOAD_DIR: /app/uploads UPLOAD_DIR: /app/uploads
LOG_DIR: /app/logs LOG_DIR: /app/logs
# Where the loader scans for installed modules. Same path the code already
# defaults to (<repo>/modules, and the repo is /app in the image), set
# explicitly because the bind mount below is what makes it meaningful.
MODULES_DIR: /app/modules
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@@ -47,6 +51,25 @@ services:
# the image, so this mount only matters for custom brand images. Create # the image, so this mount only matters for custom brand images. Create
# ./brand/ on the host and drop assets in; read-only in the container. # ./brand/ on the host and drop assets in; read-only in the container.
- ./brand:/app/brand:ro - ./brand:/app/brand:ro
# Installed modules (docs/website/MODULE_SYSTEM.md). Modules live on a
# mount, NEVER in the image: that is what lets an operator add one to a
# pull-only deployment without building anything. A bind mount rather than
# a named volume because placing a module directory by hand is a supported
# install — `tar -xf uo-1.0.0.tgz -C ./modules` then restart — and that has
# to be doable from the host, not through `docker cp`.
#
# Read-WRITE: the admin panel's install/uninstall unpacks and removes
# directories here from inside the container.
#
# `modules/` is tracked (it ships a README) so the directory exists in the
# checkout with the operator's own ownership. Do not delete it — Docker
# would recreate a missing bind-mount source as root:root and the container
# user could no longer write it. If the app runs as a uid that does not own
# ./modules, `chown 1000:1000 modules` on the host.
#
# Adding or removing a module takes a RESTART: the scan is synchronous at
# require time (MODULE_API.md §4.1), so nothing here is picked up live.
- ./modules:/app/modules
# Only the PUBLIC API port (3000) is published. The internal server<->bot # Only the PUBLIC API port (3000) is published. The internal server<->bot
# port (INTERNAL_PORT, default 3001) is deliberately NOT listed here, so it # port (INTERNAL_PORT, default 3001) is deliberately NOT listed here, so it
# stays reachable only over the private compose network — Pangolin/the public # stays reachable only over the private compose network — Pangolin/the public
@@ -74,9 +97,20 @@ services:
volumes: volumes:
- ntfydata:/var/lib/ntfy - ntfydata:/var/lib/ntfy
- ./ntfy/server.yml:/etc/ntfy/server.yml:ro - ./ntfy/server.yml:/etc/ntfy/server.yml:ro
# No published host port — devices reach ntfy through the public reverse proxy # Published so the PUBLIC reverse proxy (Pangolin) can forward the
# on its own hostname; the backend publisher reaches it over the private # notification subdomain here. Pangolin lives OUTSIDE the compose network and
# compose network. Never publish this directly. # reaches every service through a published host port — never by joining the
# internal network — exactly like `app` above (3000). So ntfy must publish a
# port too: the reverse proxy maps notify.<host> -> host:NTFY_HOST_PORT ->
# ntfy:80. Unlike INTERNAL_PORT / the bot, ntfy is DEVICE-facing, so it is
# SUPPOSED to be reachable through the proxy. Binds 0.0.0.0 (no 127.0.0.1
# prefix) so Pangolin can reach the container. Both the app (SSE subscribe) and
# the backend (POSTing content-free tickles to each device's registered
# endpoint) reach ntfy on this same public origin — NTFY_ALLOWED_ORIGINS pins
# it — so all ntfy traffic flows through the proxy; there is no separate
# internal publish port.
ports:
- "${NTFY_HOST_PORT:-2586}:80"
bot: bot:
# Same as app: prebuilt bot image, pulled in production. Build locally via # Same as app: prebuilt bot image, pulled in production. Build locally via

75
modules/README.md Normal file
View File

@@ -0,0 +1,75 @@
# Installed modules
This directory is bind-mounted into the container at `/app/modules` (see
`docker-compose.yml`). It is where **installed modules** live — the game-specific
routes, tables, screens and nav that are not part of core. Design of record:
[`docs/website/MODULE_SYSTEM.md`](../../docs/website/MODULE_SYSTEM.md); the
normative contract module authors build against is
[`docs/website/MODULE_API.md`](../../docs/website/MODULE_API.md).
**Core ships no module.** This directory is empty in a fresh checkout, and the
site runs cleanly that way — an empty `modules/` is the normal state for bare
core, not a misconfiguration. Everything below is ignored by git except this
README, which exists so the directory itself is tracked: `docker-compose.yml`
bind-mounts it, and Docker recreates a *missing* bind-mount source as a
root-owned directory the container user cannot write.
## Layout
One directory per module, named for its id, each holding a prebuilt bundle:
```
modules/
uo/
module.json # the manifest the loader reads
server/index.js # registers routes, streams, hooks
server/db/schema.sql # tables, replayed every boot
server/db/purge.sql # only ever run by an explicit purge
client/dist/entry.js # prebuilt ESM chunk, served at /modules/uo/
```
**Nothing here is compiled by the operator.** A module arrives already built —
that is the whole point of the design. There is no install step that runs a
bundler, and none that needs one.
## Installing a module
Two supported paths, both writing the same `installed_modules` row:
- **The admin panel** downloads the bundle from the module's release, verifies it
against its `sha256`, and unpacks it here.
- **By hand**, for a compose-managed host: unpack the bundle into a directory
named for the module id, e.g. `tar -xf uo-1.0.0.tgz -C ./modules`.
Either way, **adding or removing a module takes a restart.** The loader scans
this directory synchronously at startup (`MODULE_API.md` §4.1); nothing placed
here is picked up by a running server.
```
docker compose restart app
```
On boot each module is validated, mounted, its schema fragment replayed and its
`onBoot` hook run — reaching `started`, or `startup_failed` with the stage and
reason recorded. A module that fails to start does not stop the site: core, and
every other module, carry on without it.
## Uninstalling
Removing a directory and restarting is enough to stop a module serving. Note that
this is *not* the same as an uninstall through the admin panel, which also marks
the row `disabled` — a directory that simply vanishes leaves a row claiming to be
enabled, which the loader records as `startup_failed`.
A module's **tables and data are retained** in both cases. Dropping them is a
separate, explicit, destructive purge; it is never bundled into an uninstall.
## Ownership
The container runs as uid 1000 (`node`) and the admin panel writes here, so the
app must be able to write this directory. It is created by your checkout, with
your ownership. If they differ:
```
chown -R 1000:1000 modules
```

View File

@@ -13,8 +13,12 @@
# a placeholder for a bare `ntfy serve`. # a placeholder for a bare `ntfy serve`.
base-url: "https://ntfy.localhost" base-url: "https://ntfy.localhost"
# Served on the private compose network; the public reverse proxy terminates TLS # ntfy listens on :80 inside the container. docker-compose.yml publishes this on
# and forwards to this port. docker-compose.yml publishes NO host port for ntfy. # a host port (NTFY_HOST_PORT, default 2586) so the public reverse proxy — which
# lives OUTSIDE the compose network — can terminate TLS and forward the
# notification subdomain to it. Both the app (SSE subscribe) and the backend
# (POSTing content-free tickles to registered device endpoints) reach ntfy on
# that public origin, so all traffic flows through the proxy.
listen-http: ":80" listen-http: ":80"
behind-proxy: true behind-proxy: true

View File

@@ -1,7 +1,7 @@
{ {
"name": "runic-gateway-website", "name": "runic-gateway-website",
"version": "1.0.0", "version": "1.0.0",
"description": "Runic Gateway — public site, wiki, and admin panel for a private Ultima Online shard", "description": "Runic Gateway — public site, wiki, and admin panel for a private game server",
"private": true, "private": true,
"scripts": { "scripts": {
"install-server": "npm install --prefix server", "install-server": "npm install --prefix server",
@@ -13,7 +13,8 @@
"bot": "npm run dev --prefix bot", "bot": "npm run dev --prefix bot",
"seed": "npm run seed --prefix server", "seed": "npm run seed --prefix server",
"build": "npm run build --prefix client", "build": "npm run build --prefix client",
"start": "npm start --prefix server" "start": "npm start --prefix server",
"check:modules": "node scripts/checkModuleIdentifiers.js"
}, },
"keywords": ["express", "mariadb", "react", "vite", "jwt"], "keywords": ["express", "mariadb", "react", "vite", "jwt"],
"author": "whitlocktech", "author": "whitlocktech",

View File

@@ -0,0 +1,328 @@
#!/usr/bin/env node
// ── §5.2 — zero module identifiers in core ─────────────────────────────────
//
// Phase 3's acceptance criterion 1, as a check rather than a review promise: no
// `shard`, `uoLink`, `cliloc`, `atlas` or `towncrier` anywhere in core's source
// (MODULE_API.md §5.2, MODULE_SYSTEM.md §2.7.1 slice 4). The extraction is only
// worth what this is worth — a boundary nothing enforces grows a hole the first
// time someone is in a hurry, and the hole looks exactly like the code that was
// there before.
//
// **It reads code, not prose, and that is the whole design.** Four things are
// checked, and each is a thing a module owns:
//
// 1. file and directory names
// 2. import and require SPECIFIERS — the path, not the file's contents
// 3. route path literals — the string handed to .get/.post/.put/.patch/
// .delete/.use
// 4. declared identifiers — function, const, class, and object property names
//
// Comments and string content in general are NOT read. Core's own English may
// legitimately say "shard": `About.jsx` did until slice 4 rewrote it, and a
// comment explaining *what moved and why* — `AcceptInvite.jsx` has one — is
// worth more than the word costs. A literal word grep would fail on both, prove
// nothing about the boundary, and teach people to phrase around it. The
// boundary this defends is structural: core must not NAME a module's files,
// import them, route to them, or declare their symbols. It may talk about them.
//
// Two things learned the hard way, both of which this file would have got wrong:
//
// • **Match on word boundaries, not substrings.** `defaultImage` contains
// "ultIma"; `atlas` is inside "atlasSomething" legitimately only when it is
// the same word. The tokeniser below splits identifiers on camelCase and
// separators and compares WHOLE words, so `shardStatus` is a hit and
// `defaultImage` is not. A substring pass flagged four innocent lines in
// this repo on its first run.
// • **Strip comments and strings with a character walk, not a regexp.** The
// module's own `checkImports.js` flagged the comments that explain what it
// catches. A comment contains quotes (`-- '' when randomised`), a string
// contains `//` (any URL), and a regexp literal contains both. Doing it in
// one pass, in order, is the only way that comes out right — and this file
// has its own test suite (`server/test/checkModuleIdentifiers.test.js`)
// because a check that silently stops checking is worse than no check.
const fs = require('fs')
const path = require('path')
const { execFileSync } = require('child_process')
const ROOT = path.resolve(__dirname, '..')
// The trees core owns. `modules/` is deliberately absent — that is where a
// module's own code lives, and it is the one place these words belong.
const TREES = [
path.join(ROOT, 'server', 'src'),
path.join(ROOT, 'server', 'scripts'),
path.join(ROOT, 'server', 'db'),
path.join(ROOT, 'client', 'src'),
]
const SKIP_DIRS = new Set(['node_modules', 'coverage', 'dist', '.git'])
const CODE = new Set(['.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx'])
// The words a module owns. Lower-cased whole words, compared against the
// tokeniser's output — so `uoLink`, `uo_link` and `uo-link` all reduce to the
// two tokens `uo` and `link`, and the pair is what is matched.
const RESERVED = new Set(['shard', 'shards', 'cliloc', 'clilocs', 'atlas', 'towncrier'])
// Sequences of tokens that are reserved together but innocent apart: "uo" and
// "link" each appear in ordinary core code ("link" especially), and only the
// pair names the sidecar.
const RESERVED_PAIRS = [['uo', 'link'], ['town', 'crier'], ['spawn', 'atlas'], ['serv', 'uo']]
// Standalone `uo` is reserved too: it is the module id, and a core file called
// `uo.js` or a route `/uo` is the boundary being crossed in the plainest way.
const RESERVED_ALONE = new Set(['uo', 'uolink', 'servuo', 'ultima'])
// ── The grandfathering exemptions ───────────────────────────────────────────
//
// Exactly three, and all three are the SAME mechanism: core's per-module legacy
// allowlists (MODULE_API.md §6.5). A table prefix, a set of stream ids and an
// announce leg all predate the module system, are stored in live rows, and are
// read by a shipped Android client — so `uo` keeps them, and keeping them means
// core holds a map whose KEY is the module id. There is no way to write that
// down without naming the module; that is what grandfathering is.
//
// Nothing else may be added here without the same kind of reason. In particular
// this is not an escape hatch for "core still needs this for now" — that is the
// state slice 4 exists to end.
//
// Each entry must MATCH something. An exemption that no longer fires is deleted
// by the check itself (`unused exemption` below), because a stale one is how an
// allowlist quietly becomes permission for whatever drifts into it later.
const EXEMPT = [
{
file: 'server/src/modules/loader.js',
name: 'uo',
kind: 'property name',
why: 'LEGACY_TABLE_PREFIXES — the grandfathered shard_/uo_link_ table prefixes (API §6.5)',
},
{
file: 'server/src/modules/registries.js',
name: 'uo',
kind: 'property name',
why: 'LEGACY_STREAM_IDS and LEGACY_LEGS — grandfathered stream ids and the towncrier leg (API §6.5)',
},
]
const isExempt = (hit) =>
EXEMPT.some((e) => e.file === hit.file && e.name === hit.name && e.kind === hit.kind)
/**
* Split a name into lower-case words: camelCase humps, and runs separated by
* `-`, `_`, `.`, `/` or digits.
*
* `shardStatus` → [shard, status]; `uo_link_config` → [uo, link, config];
* `defaultImage` → [default, image] — which is the point: the substring
* "ultIma" inside it is not a word and never appears here.
*/
function tokenize(name) {
return String(name)
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
.split(/[^A-Za-z]+/)
.filter(Boolean)
.map((w) => w.toLowerCase())
}
/** Does this name contain a reserved word, as a word? */
function reservedWordIn(name) {
const words = tokenize(name)
for (const w of words) {
if (RESERVED.has(w) || RESERVED_ALONE.has(w)) return w
}
for (const [a, b] of RESERVED_PAIRS) {
for (let i = 0; i < words.length - 1; i++) {
if (words[i] === a && words[i + 1] === b) return `${a}-${b}`
}
}
return null
}
/**
* Blank out comments, and MASK string/template/regexp contents, in one
* left-to-right pass.
*
* Masking rather than deleting: the checks that run afterwards need to know
* WHERE a string was (a route path literal is a string) while not reading what
* is in an arbitrary one. So a string's delimiters and length survive and its
* body becomes spaces, except that the string-literal check below re-reads the
* original text at the same offsets. Comments are replaced by spaces so every
* offset in the returned text still lines up with the input — line numbers stay
* honest without a second pass.
*/
function maskCode(src) {
const out = Array.from(src)
const blank = (from, to) => {
for (let i = from; i < to && i < out.length; i++) if (out[i] !== '\n') out[i] = ' '
}
let i = 0
while (i < src.length) {
const c = src[i]
const next = src[i + 1]
if (c === '/' && next === '/') {
let j = i
while (j < src.length && src[j] !== '\n') j++
blank(i, j)
i = j
continue
}
if (c === '/' && next === '*') {
const end = src.indexOf('*/', i + 2)
const j = end === -1 ? src.length : end + 2
blank(i, j)
i = j
continue
}
if (c === '-' && next === '-' && src[i + 2] === ' ') {
// SQL line comment; harmless in JS, where `-- ` cannot start an expression.
let j = i
while (j < src.length && src[j] !== '\n') j++
blank(i, j)
i = j
continue
}
if (c === '"' || c === "'" || c === '`') {
let j = i + 1
while (j < src.length) {
if (src[j] === '\\') { j += 2; continue }
if (src[j] === c) break
j++
}
blank(i + 1, j) // keep the quotes, blank the body
i = j + 1
continue
}
i++
}
return out.join('')
}
// ── the four checks ─────────────────────────────────────────────────────────
const SPECIFIER = /(?:require\(\s*|from\s+|import\(\s*)(['"])([^'"]+)\1/g
const ROUTE = /\.(?:get|post|put|patch|delete|use|all)\(\s*(['"`])([^'"`]*)\1/g
const DECLARED = /\b(?:function|const|let|var|class)\s+([A-Za-z_$][\w$]*)/g
const PROPERTY = /(?:^|[{,]\s*)([A-Za-z_$][\w$]*)\s*:/gm
function lineOf(src, index) {
return src.slice(0, index).split('\n').length
}
/**
* Check one file. `src` is the raw text; `masked` has comments blanked and
* string bodies blanked at the same offsets, so a regexp run over `masked`
* finds only real code — and the captured offsets index back into `src` when a
* check legitimately needs the string's content (specifiers and route paths).
*/
function checkFile(rel, src) {
const hits = []
const masked = maskCode(src)
const add = (kind, name, index) => {
const word = reservedWordIn(name)
if (word) hits.push({ file: rel, line: lineOf(src, index), kind, name, word })
}
for (const m of masked.matchAll(SPECIFIER)) {
// Read the specifier out of the ORIGINAL text: its body was masked, and a
// path is the one string whose content is structural.
const start = m.index + m[0].indexOf(m[1]) + 1
add('import specifier', src.slice(start, start + m[2].length), m.index)
}
for (const m of masked.matchAll(ROUTE)) {
const start = m.index + m[0].indexOf(m[1]) + 1
add('route path', src.slice(start, start + m[2].length), m.index)
}
for (const m of masked.matchAll(DECLARED)) add('declared identifier', m[1], m.index)
for (const m of masked.matchAll(PROPERTY)) add('property name', m[1], m.index)
return hits
}
function walk(dir, out = []) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (SKIP_DIRS.has(entry.name)) continue
const full = path.join(dir, entry.name)
if (entry.isDirectory()) walk(full, out)
else out.push(full)
}
return out
}
/**
* The files core SHIPS, which is what the boundary is about — not whatever
* happens to be in a working tree.
*
* `git ls-files` rather than a walk, because an untracked local artifact is not
* core's source and must not fail anyone's build. This is not hypothetical: an
* operator-supplied `server/db/data/spawnAtlas.art.json` is gitignored, sits in
* the tree of anyone who has run the atlas import, and would otherwise report a
* file-name violation that no commit could fix. The walk stays as the fallback
* for an export with no git in it, where over-reporting is the safer failure.
*/
function sourceFiles() {
try {
const out = execFileSync('git', ['ls-files', '-z', '--cached', '--', ...TREES.map((t) => path.relative(ROOT, t))], {
cwd: ROOT,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
})
const files = out.split('\0').filter(Boolean).map((f) => path.join(ROOT, f))
if (files.length) return files
} catch {
// no git, or not a checkout — fall through
}
return TREES.filter((t) => fs.existsSync(t)).flatMap((t) => walk(t))
}
function run() {
const hits = []
for (const file of sourceFiles()) {
if (!fs.existsSync(file)) continue
const rel = path.relative(ROOT, file).split(path.sep).join('/')
// 1. the name itself
const word = reservedWordIn(path.basename(file))
if (word) hits.push({ file: rel, line: 0, kind: 'file name', name: path.basename(file), word })
// 2-4. the contents, for code files only
if (!CODE.has(path.extname(file))) continue
hits.push(...checkFile(rel, fs.readFileSync(file, 'utf8')))
}
const live = hits.filter((h) => !isExempt(h))
// A grandfathering entry that matches nothing is deleted, loudly. Reported as
// a failure rather than a warning: the exemption list is the one part of this
// check that can only get weaker, so it is the part that needs the noise.
const unused = EXEMPT.filter((e) => !hits.some((h) => h.file === e.file && h.name === e.name && h.kind === e.kind))
return { hits: live, unused }
}
module.exports = { run, checkFile, maskCode, tokenize, reservedWordIn, EXEMPT }
if (require.main === module) {
const { hits, unused } = run()
if (hits.length === 0 && unused.length === 0) {
console.log('OK — core names no module identifier (MODULE_API.md §5.2).')
process.exit(0)
}
for (const e of unused) {
console.error(
`\nUnused exemption: ${e.file} "${e.name}" (${e.kind}) matches nothing any more.\n` +
` ${e.why}\n` +
' Delete it from EXEMPT in this file. A grandfathering entry that has outlived what it ' +
'grandfathered is permission with nothing attached to it.',
)
}
if (hits.length === 0) process.exit(1)
console.error(
`\nCore names ${hits.length} module identifier${hits.length === 1 ? '' : 's'} ` +
'(MODULE_API.md §5.2). Each of these belongs to an installed module:\n',
)
for (const h of hits) {
console.error(` ${h.file}:${h.line} ${h.kind} "${h.name}" — reserved word "${h.word}"`)
}
console.error(
'\nCore may TALK about a module in English; it may not name its files, import them, ' +
'route to them, or declare its symbols. If one of these is core\'s own and the word is ' +
'a coincidence, the fix is to rename it — the reserved list is short and deliberate.\n',
)
process.exit(1)
}

View File

@@ -99,13 +99,15 @@ CLIENT_ORIGIN=http://localhost:5173
BOT_INTERNAL_URL=http://localhost:4100 BOT_INTERNAL_URL=http://localhost:4100
BOT_INTERNAL_KEY=dev-only-change-me-bot-key BOT_INTERNAL_KEY=dev-only-change-me-bot-key
# News announcement pipeline (published news post -> in-game town crier + Discord # News announcement pipeline (published news post -> every registered delivery
# #news). The dispatcher is an in-process poller; these tune it. Links in the # leg). The dispatcher is an in-process poller; this tunes it. Links in the
# announcements use APP_BASE_URL (set above), so set that in production too. # announcements use APP_BASE_URL (set above), so set that in production too.
# ANNOUNCE_POLL_MS how often the dispatcher sweeps for due/retry legs #
# TOWNCRIER_DURATION_SEC how long the in-game town-crier message stays up (<= 86400) # Which legs exist depends on what has registered one: Discord (#news) is core's,
# and an installed module may add its own. A module's leg brings its own settings
# with it -- module-uo's in-game town crier reads TOWNCRIER_DURATION_SEC, which is
# documented in that module rather than here, because core has no town crier.
ANNOUNCE_POLL_MS=15000 ANNOUNCE_POLL_MS=15000
TOWNCRIER_DURATION_SEC=3600
# Push notifications (M7) — opt-in fan-out to the Android app via a self-hosted # Push notifications (M7) — opt-in fan-out to the Android app via a self-hosted
# ntfy UnifiedPush relay (docs/android/PLAN.md §11). The publisher POSTs # ntfy UnifiedPush relay (docs/android/PLAN.md §11). The publisher POSTs
@@ -122,8 +124,24 @@ TOWNCRIER_DURATION_SEC=3600
# NTFY_PUBLISH_TOKEN Optional bearer token for backend->ntfy publishes (off by default). # NTFY_PUBLISH_TOKEN Optional bearer token for backend->ntfy publishes (off by default).
# Leave NTFY_BASE_URL unset in local dev to allow any public HTTPS endpoint # Leave NTFY_BASE_URL unset in local dev to allow any public HTTPS endpoint
# (private/loopback hosts are always rejected). Without NTFY_PUBLIC_URL / # (private/loopback hosts are always rejected). Without NTFY_PUBLIC_URL /
# NTFY_ALLOWED_ORIGINS the app shows push as unavailable for the shard. # NTFY_ALLOWED_ORIGINS the app shows push as unavailable for this instance.
# NTFY_BASE_URL=https://ntfy.example.com # NTFY_BASE_URL=https://ntfy.example.com
# NTFY_PUBLIC_URL=https://ntfy.example.com # NTFY_PUBLIC_URL=https://ntfy.example.com
# NTFY_ALLOWED_ORIGINS=https://ntfy.example.com # NTFY_ALLOWED_ORIGINS=https://ntfy.example.com
# NTFY_PUBLISH_TOKEN= # NTFY_PUBLISH_TOKEN=
# Modules (MODULE_SYSTEM.md §2.5) — where installable modules live, and where
# they may be installed from.
# MODULES_DIR Directory the loader scans at require time. Defaults to
# <repo>/modules; docker-compose.yml sets it to /app/modules,
# which is the bind mount that makes it meaningful.
# MODULE_SOURCE_HOSTS BOOTSTRAP ONLY. Comma-separated hostnames the admin panel
# may install a module from, seeded into the `module_source_hosts`
# setting the first time the site boots without one. From then
# on the SETTING is authoritative and is edited in
# Admin → Modules — changing this variable on an existing
# deployment does nothing, deliberately, so a redeploy cannot
# silently undo an operator's choice. Installs are https-only
# and an empty list forbids all of them.
# MODULES_DIR=/app/modules
# MODULE_SOURCE_HOSTS=gitea.whitlocktech.com

Some files were not shown because too many files have changed in this diff Show More