83 Commits

Author SHA1 Message Date
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
514bc9d23c Merge pull request 'feat(auth): trusted devices, recovery codes, and admin MFA management' (#93) from feature/trusted-devices-mfa into main
All checks were successful
Build container images / build (push) Successful in 1m3s
Build container images / deploy (push) Successful in 36s
SonarQube / analysis (push) Successful in 2m32s
Reviewed-on: #93
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 05:09:04 +00:00
60ebacff2c feat(auth): trusted devices, recovery codes, and admin MFA management
All checks were successful
PR Checks / bot-install (pull_request) Successful in 19s
PR Checks / server-tests (pull_request) Successful in 42s
PR Checks / client-build (pull_request) Successful in 9m24s
Add opt-in "Trust this device" so a browser/app skips the TOTP step (never
the password) for 30 days, single-use bcrypt recovery codes as a 2FA-lockout
fallback, and admin trusted-device/MFA-reset management — backend, web UI,
OpenAPI spec, and tests.

- Schema: trusted_devices (sha256 token hash, looked up by unique index) and
  recovery_codes (bcrypt, single-use). Both additive/idempotent.
- Session service: trust-token mint/hash/resolve + cap helpers; new rg_trust
  httpOnly cookie (survives logout, revoked on untrust/password change/reset/
  TOTP disable). JWTs stay stateless — trust is a server-side row, not a claim.
- Web + mobile login accept a trusted-device token / recovery code; login/totp
  gains trustDevice + recoveryCode. Cap of 10/user with NO silent pruning — an
  over-cap trust returns 409/trustLimitReached and the client prompts to revoke.
- Self-service /auth/me/trusted-devices* + recovery-codes*; admin
  /admin/users/:id/trusted-devices* + /mfa/reset. All actions audit-logged.
- Client: "Trust this device" + recovery-code login options, one-time recovery
  code display, Trusted Devices + Recovery Codes account panels, a TOTP-styled
  revoke-to-continue cap modal, and admin per-user security controls.
- OpenAPI regenerated; 33 new server tests (all suites green).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 23:38:48 -05:00
8d5bdc0d6e Merge pull request 'docs(readme): add architecture mermaid diagram' (#92) from docs/website-architecture-diagram into main
All checks were successful
Build container images / build (push) Successful in 1m5s
Build container images / deploy (push) Successful in 40s
SonarQube / analysis (push) Successful in 2m23s
Reviewed-on: #92
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 02:17:16 +00:00
c991a07c8a docs(readme): add architecture mermaid diagram
All checks were successful
PR Checks / bot-install (pull_request) Successful in 14s
PR Checks / client-build (pull_request) Successful in 23s
PR Checks / server-tests (pull_request) Successful in 35s
Add an Architecture section with a Mermaid diagram of the full data path
(SPA/mobile clients -> layered Express backend -> MariaDB, and the uo-link
sidecar bridge to the ServUO shard) plus a Contents entry. Same diagram is
mirrored in the docs repo (docs/website/ARCHITECTURE.md).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 21:13:11 -05:00
4c13706958 Merge pull request 'chore(dev): stub OAuth IdP tooling for local mobile SSO testing' (#91) from feat/m10-native-sso-fix into main
All checks were successful
Build container images / build (push) Successful in 57s
Build container images / deploy (push) Successful in 37s
SonarQube / analysis (push) Successful in 11m55s
Reviewed-on: #91
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-21 21:42:57 +00:00
2306545574 chore(dev): add viewport meta to the stub IdP picker page
All checks were successful
PR Checks / bot-install (pull_request) Successful in 13s
PR Checks / client-build (pull_request) Successful in 9m24s
PR Checks / server-tests (pull_request) Successful in 10m47s
So the dev stub IdP's account-picker renders at the correct mobile size when it
opens in an Android Custom Tab during SSO testing.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 16:28:46 -05:00
70849f96ee chore(dev): add stub OAuth IdP + seed + bridge smoketest for native SSO
Dev environments have no real OAuth provider configured, so GET /auth/providers
returns [] and the native mobile SSO flow cannot be exercised locally. Add
dependency-free dev tooling under scripts/dev/:

- stub-idp.js: stub OAuth2/OIDC IdP (authorize picker, token, userinfo)
- seed-sso-provider.js: registers a 'devstub' auth_providers row + pre-links
  each principal's sub to a dev account (SSO is link-only)
- sso-bridge-smoketest.js: drives the full app flow headless (PKCE → start →
  IdP → callback → deep link → exchange) and asserts a bearer pair
- README.md: host + emulator usage

Verified end-to-end against the local site: player and admin principals both
sign in and receive the correct role. DEV ONLY — never deploy the stub.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 14:56:43 -05:00
1edef8e6db Merge pull request 'fix(footer): point Shard Status link to /site/shard' (#90) from fix/footer-shard-status-link into main
All checks were successful
Build container images / build (push) Successful in 2m21s
SonarQube / analysis (push) Successful in 2m27s
Build container images / deploy (push) Successful in 37s
Reviewed-on: #90
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-21 19:10:48 +00:00
dc90df9fff fix(footer): point Shard Status link to /site/shard
All checks were successful
PR Checks / bot-install (pull_request) Successful in 15s
PR Checks / client-build (pull_request) Successful in 25s
PR Checks / server-tests (pull_request) Successful in 38s
The footer's "Shard Status" link targeted /site/status; point it at the
richer live shard page at /site/shard.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 14:09:47 -05:00
68126efc0b Merge pull request 'refactor(server): dedupe shard-state shaping, upsert builder, and config DB models' (#89) from refactor/dedupe-shardstate-config-db into main
All checks were successful
Build container images / build (push) Successful in 1m4s
Build container images / deploy (push) Successful in 39s
SonarQube / analysis (push) Successful in 12m11s
Reviewed-on: #89
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-21 17:49:31 +00:00
401db8f75c refactor(server): dedupe shard-state shaping, upsert builder, and config DB models
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 11m4s
Address the SonarQube copy-paste findings that reflect real duplication (as
opposed to the intentional cross-package / admin-player mirror copies, which
are by-design and left as-is):

- shardState.model.js: listOnline() re-inlined the exact field mapping that
  shapeOnline() already provides (used by listOnlineLinked). Collapse it onto
  shapeOnline so the two can no longer drift.
- shardState.db.js: extract a single upsertRow(table, pkCol, pk, fields,
  {coalesce}) builder for the five near-identical INSERT ... ON DUPLICATE KEY
  UPDATE bodies (online/houses/champs/guilds/governors). shard_online keeps its
  COALESCE-on-NULL semantics via the coalesce flag.
- botConfig/emailConfig/uoLinkConfig .db.js: generate get()/upsert() from a
  shared singletonConfigDb(table, cols) factory instead of three byte-identical
  copies.

Behavior unchanged; full server suite (381 tests) passes.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 12:35:56 -05:00
9b0f2d93d8 Merge pull request 'chore(quality): resolve SonarQube code smells across website' (#88) from chore/sonar-code-smells into main
All checks were successful
Build container images / build (push) Successful in 1m19s
Build container images / deploy (push) Successful in 39s
SonarQube / analysis (push) Successful in 2m29s
Reviewed-on: #88
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-21 14:27:56 +00:00
12d50fd615 chore(quality): resolve SonarQube code smells across website
All checks were successful
PR Checks / bot-install (pull_request) Successful in 13s
PR Checks / client-build (pull_request) Successful in 22s
PR Checks / server-tests (pull_request) Successful in 11m13s
Clears the 124 CODE_SMELL findings from the SonarQube scan (server, client,
and bot). All changes are behaviour-preserving refactors — no route, protocol,
schema, or config changes — verified against the full server (381) and client
(43) test suites plus a clean client build.

By rule:
- S3776 (20, cognitive complexity): extract helpers/handlers so each function
  drops under the threshold — shard model upsert builders, page/wiki update,
  block validation, notification stream mapping (dispatch table), SSO mobile
  login, shard ingest deps, uo-link socket backfill/connect, the bot slash-
  command dispatchers + discord manager, and the Shard/UserDetail/HeroEditor/
  CharacterStats React components.
- S4624 (34, nested template literals): pull inner templates into locals /
  a withQs() helper; rewrite shardEvents.describe() as a formatter table.
- S3358 (35, nested ternaries): lift to if/else vars, lookup maps, small
  components, or guarded JSX expressions.
- S6479 (12, array-index React keys): key by stable content instead of index
  (two in-editor lists left as-is; index matches their by-index edit model).
- S6353 (6): [0-9]/[^0-9] -> \d/\D.  S125 (5): reword state-shape comments that
  parsed as code.  S3800/S3782 (botScore): JSDoc-type PATH_WEIGHTS tuples.
- S6481 (2): memoize Auth/Site context values (and SiteContext brand).
- S4144: dedupe HeroEditor upload handler into useImageUpload().
- S1126 (2), S6035, S5869 (redundant A-Z under /i), S5843 (town-name regex ->
  prefix list): assorted one-liners.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 04:35:39 -05:00
4993470fa2 Merge pull request 'ci(sonarqube): populate the "Unit Tests" measure via a test-execution report' (#87) from ci/sonar-test-execution-report into main
All checks were successful
Build container images / build (push) Successful in 59s
Build container images / deploy (push) Successful in 37s
SonarQube / analysis (push) Successful in 2m28s
Reviewed-on: #87
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-21 06:26:40 +00:00
2f3e8f7df5 ci(sonarqube): report test execution so the Unit Tests measure populates
All checks were successful
PR Checks / bot-install (pull_request) Successful in 14s
PR Checks / client-build (pull_request) Successful in 23s
PR Checks / server-tests (pull_request) Successful in 9m25s
The lcov reports only feed SonarQube's Coverage metric — the "Unit Tests" tile
stayed "-" because we never provided a test-execution report (a separate input
via sonar.testExecutionReportPaths, in SonarQube's own Generic Test Execution
XML format, which the lcov/junit reporters don't produce).

Add a dependency-free custom node:test reporter (scripts/sonar-test-reporter.mjs)
that emits that XML — repo-root-relative <file path> entries matching sonar.tests,
integer-ms durations — and wire it into both the server and client coverage runs
in sonarqube.yml, plus sonar.testExecutionReportPaths in sonar-project.properties.

Verified locally: server 380 + client 43 test cases, well-formed XML, all three
reporters (spec/lcov/sonar) coexist in one `node --test` invocation.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 01:16:08 -05:00
272 changed files with 45304 additions and 11082 deletions

View File

@@ -117,7 +117,10 @@ BOT_INTERNAL_KEY=change-me-to-a-long-random-string
# token). These URLs are just defaults; the admin can override them at runtime. # token). These URLs are just defaults; the admin can override them at runtime.
UOLINK_BASE_URL=http://127.0.0.1:8080 UOLINK_BASE_URL=http://127.0.0.1:8080
UOLINK_WS_URL=ws://127.0.0.1:8080/ws UOLINK_WS_URL=ws://127.0.0.1:8080/ws
UOLINK_PROTOCOL=1 # Wire protocol this build speaks (3 = Protocol 3.0). Only a fallback for a site
# with nothing saved yet — the admin panel's pinned value wins — but set it lower
# if you deliberately run an older sidecar.
UOLINK_PROTOCOL=3
# ─── 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 +134,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

@@ -40,6 +40,13 @@ jobs:
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

View File

@@ -64,6 +64,7 @@ jobs:
node --test --experimental-test-coverage \ node --test --experimental-test-coverage \
--test-reporter=spec --test-reporter-destination=stdout \ --test-reporter=spec --test-reporter-destination=stdout \
--test-reporter=lcov --test-reporter-destination=server/coverage/lcov.info \ --test-reporter=lcov --test-reporter-destination=server/coverage/lcov.info \
--test-reporter=./scripts/sonar-test-reporter.mjs --test-reporter-destination=server/coverage/test-execution.xml \
server/test/*.test.js server/test/*.test.js
- name: Generate client test coverage (LCOV) - name: Generate client test coverage (LCOV)
@@ -76,6 +77,7 @@ jobs:
node --test --experimental-test-coverage \ node --test --experimental-test-coverage \
--test-reporter=spec --test-reporter-destination=stdout \ --test-reporter=spec --test-reporter-destination=stdout \
--test-reporter=lcov --test-reporter-destination=client/coverage/lcov.info \ --test-reporter=lcov --test-reporter-destination=client/coverage/lcov.info \
--test-reporter=./scripts/sonar-test-reporter.mjs --test-reporter-destination=client/coverage/test-execution.xml \
client/test/*.test.js client/test/*.test.js
- name: Run SonarQube scan - name: Run SonarQube scan

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

18
.gitignore vendored
View File

@@ -21,6 +21,24 @@ uploads/
server/logs/ server/logs/
logs/ logs/
# 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

149
README.md
View File

@@ -25,6 +25,7 @@ The design reference is [BACKEND_DESIGN.md](https://gitea.whitlocktech.com/Runic
## Contents ## Contents
- [Architecture](#architecture)
- [Tech stack](#tech-stack) - [Tech stack](#tech-stack)
- [Project structure](#project-structure) - [Project structure](#project-structure)
- [Prerequisites](#prerequisites) - [Prerequisites](#prerequisites)
@@ -44,6 +45,102 @@ The design reference is [BACKEND_DESIGN.md](https://gitea.whitlocktech.com/Runic
--- ---
## Architecture
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
game world only through the **uo-link** sidecar. The shard itself is never internet-facing.
```mermaid
flowchart TB
%% ---------- Clients ----------
subgraph clients["Clients"]
browser["Browser<br/>React + Vite SPA<br/>(public · wiki · admin)"]
mobile["Native mobile app<br/>(bearer tokens)"]
end
idp["SSO providers<br/>Google · Discord · custom OIDC"]
discord["Discord"]
%% ---------- Website (one repo) ----------
subgraph website["website/ &nbsp;— Node app (one repo)"]
direction TB
subgraph backend["server/ — Express backend"]
direction TB
mw["Middleware<br/>helmet · siteMode · noindex<br/>rateLimit · loginProtection · botScore · validate"]
router["Router /api/v1<br/>auth (web · mobile · sso) · public · admin"]
ctrl["Controllers"]
auth["Session layer (auth/)<br/>sessionService · JWT/cookie · bearer · SSO+PKCE"]
model["Models (.model + .db)<br/>raw parameterized SQL — no ORM"]
sse["SSE fan-out<br/>public stream (allowlist) · admin stream (sensitive)"]
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"]
end
bot["bot/<br/>Discord bot"]
end
db[("MariaDB<br/>users · posts · wiki · settings · activity<br/>mobileSessions · authProviders · userIdentities<br/>uoLinkConfig · shard_online/economy/houses/events")]
%% ---------- Shard side ----------
subgraph shardside["Game shard (never internet-facing)"]
direction TB
sidecar["uo-link sidecar<br/>(Rust) — the only bridge exposed"]
servuo["ServUO shard<br/>(C# plugin)"]
end
%% ---------- Edges ----------
browser <-->|"same-origin JSON + SSE (cookie)"| mw
mobile -->|"REST (bearer access/refresh)"| mw
browser -.->|"OAuth redirect + PKCE"| idp
auth -.->|"token exchange"| idp
mw --> router --> ctrl
ctrl --> auth
ctrl --> model
ctrl --> restcli
ctrl --> sse
auth --> model
model <--> db
auth -. reads/writes secrets .-> secret
restcli -. reads config/token .-> secret
ingest --> model
ingest --> sse
sse -->|"live events"| browser
bot -->|"messages"| discord
bot <--> db
restcli -->|"REST: /char /roster /economy /history · /link/confirm · /towncrier"| sidecar
sidecar -->|"WebSocket live event feed (bearer + X-UOLink-Version)"| ingest
servuo -->|"loopback TCP 127.0.0.1:7788<br/>newline-delimited JSON (shard dials out)"| sidecar
%% ---------- Styling ----------
classDef ext fill:#2d2233,stroke:#7a5c94,color:#e8dff0;
classDef store fill:#1f2d2a,stroke:#4c8c7d,color:#dff0ea;
classDef bridge fill:#2d2620,stroke:#94764c,color:#f0e6d8;
class idp,discord ext;
class db store;
class sidecar,servuo bridge;
```
- **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
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.
- **The shard is never reachable.** The ServUO shard *dials out* over loopback TCP to the uo-link
sidecar; only the sidecar is exposed, and only the backend talks to it. The REST client
(`uoLinkClient.js`) never throws, so the site degrades gracefully when the shard is down.
- **Sensitive events stay private.** Ingested game 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.
---
## Tech stack ## Tech stack
| Layer | Tech | | Layer | Tech |
@@ -279,6 +376,35 @@ 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 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` and `/brand`
are filesystem-conditional static mounts, not API contract, so they are excluded and the output does
not depend on whether the client has been built.
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) ## Shard integration (uo-link)
@@ -290,6 +416,29 @@ exposes a small, authenticated HTTP + WebSocket API; this website is a *client*
itself is never exposed to the internet — only the sidecar is, and only the website's backend talks itself is never exposed to the internet — only the sidecar is, and only the website's backend talks
to it. to it.
### Setting up the shard side
You do not build or place any of it by hand. The
**[Runic Gateway installer](https://gitea.whitlocktech.com/RunicGateway/installer)** runs on the
shard host, deploys the ServUO plugin and the uo-link sidecar as a matched, protocol-checked pair,
registers the sidecar as a service, and ends by printing the four values this site needs:
```
Base URL http://<shard-host>:8080
WebSocket URL ws://<shard-host>:8080/ws
Protocol version 3
Auth token 4f9c…
```
Paste them into **Admin → Shard** here and the bridge is live. The operator guide is
[installer/INSTALL.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md);
its [Appendix A](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md#appendix-a--installing-by-hand)
is the same deployment done by hand, still supported, for a host that cannot run the binary or a
developer working from a source tree.
Nothing here needs the shard to exist: with no sidecar configured the site renders normally and
shows the shard offline.
### How it works ### How it works
``` ```

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

@@ -4,6 +4,46 @@ const guildConfig = require('../../model/guildConfig')
const inviteLog = require('../../model/inviteLog') const inviteLog = require('../../model/inviteLog')
const inviteRotator = require('../../invites/inviteRotator') const inviteRotator = require('../../invites/inviteRotator')
// Per-subcommand handlers, split out of execute() so the dispatch stays flat.
async function handleChannel(interaction) {
const channel = interaction.options.getChannel('channel')
if (!channel) {
const currentId = await guildConfig.getInviteChannelId(interaction.guildId)
const content = currentId ? `Invites are created in <#${currentId}>.` : 'No invite channel is set yet.'
await interaction.reply({ content, ephemeral: true })
return
}
await guildConfig.setInviteChannelId(interaction.guildId, channel.id)
await interaction.reply({ content: `Invite channel set to ${channel}.`, ephemeral: true })
}
async function handleRotate(interaction) {
await interaction.deferReply({ ephemeral: true })
try {
const invite = await inviteRotator.rotate(interaction.client, interaction.guildId, {
triggeredBy: interaction.user.id,
triggeredByTag: interaction.user.tag,
})
await interaction.editReply({ content: `New invite: https://discord.gg/${invite.code}` })
} catch (err) {
await interaction.editReply({ content: `Couldn't rotate the invite: ${err.message}` })
}
}
async function handleLog(interaction) {
const rows = await inviteLog.list(interaction.guildId, 10)
if (rows.length === 0) {
await interaction.reply({ content: 'No invite rotations logged yet.', ephemeral: true })
return
}
const lines = rows.map((r) => {
const who = r.triggered_by_tag || 'automatic (scheduled)'
const status = r.revoked_at ? `revoked ${new Date(r.revoked_at).toLocaleString()}` : 'active'
return `\`${r.invite_code}\` — by ${who} on ${new Date(r.created_at).toLocaleString()} (${status})`
})
await interaction.reply({ content: lines.join('\n'), ephemeral: true })
}
module.exports = { module.exports = {
data: { data: {
name: 'invite', name: 'invite',
@@ -40,46 +80,8 @@ module.exports = {
}, },
async execute(interaction) { async execute(interaction) {
const sub = interaction.options.getSubcommand() const sub = interaction.options.getSubcommand()
if (sub === 'channel') return handleChannel(interaction)
if (sub === 'channel') { if (sub === 'rotate') return handleRotate(interaction)
const channel = interaction.options.getChannel('channel') if (sub === 'log') return handleLog(interaction)
if (!channel) {
const currentId = await guildConfig.getInviteChannelId(interaction.guildId)
const content = currentId ? `Invites are created in <#${currentId}>.` : 'No invite channel is set yet.'
await interaction.reply({ content, ephemeral: true })
return
}
await guildConfig.setInviteChannelId(interaction.guildId, channel.id)
await interaction.reply({ content: `Invite channel set to ${channel}.`, ephemeral: true })
return
}
if (sub === 'rotate') {
await interaction.deferReply({ ephemeral: true })
try {
const invite = await inviteRotator.rotate(interaction.client, interaction.guildId, {
triggeredBy: interaction.user.id,
triggeredByTag: interaction.user.tag,
})
await interaction.editReply({ content: `New invite: https://discord.gg/${invite.code}` })
} catch (err) {
await interaction.editReply({ content: `Couldn't rotate the invite: ${err.message}` })
}
return
}
if (sub === 'log') {
const rows = await inviteLog.list(interaction.guildId, 10)
if (rows.length === 0) {
await interaction.reply({ content: 'No invite rotations logged yet.', ephemeral: true })
return
}
const lines = rows.map((r) => {
const who = r.triggered_by_tag || 'automatic (scheduled)'
const status = r.revoked_at ? `revoked ${new Date(r.revoked_at).toLocaleString()}` : 'active'
return `\`${r.invite_code}\` — by ${who} on ${new Date(r.created_at).toLocaleString()} (${status})`
})
await interaction.reply({ content: lines.join('\n'), ephemeral: true })
}
}, },
} }

View File

@@ -5,6 +5,72 @@ const scheduledMessages = require('../../model/scheduledMessages')
const scheduler = require('../../scheduler/scheduler') const scheduler = require('../../scheduler/scheduler')
const { parseDuration } = require('../../utils/duration') const { parseDuration } = require('../../utils/duration')
// Per-subcommand handlers, split out of execute() so the dispatch stays flat.
async function handleRecurring(interaction) {
const channel = interaction.options.getChannel('channel', true)
const cronExpr = interaction.options.getString('cron', true)
const message = interaction.options.getString('message', true)
if (!cron.validate(cronExpr)) {
await interaction.reply({ content: `"${cronExpr}" isn't a valid cron expression.`, ephemeral: true })
return
}
const id = await scheduledMessages.addRecurring({
guildId: interaction.guildId,
channelId: channel.id,
content: message,
cronExpression: cronExpr,
createdBy: interaction.user.id,
createdByTag: interaction.user.tag,
})
await scheduler.refresh()
await interaction.reply({ content: `Scheduled recurring message #${id} in ${channel} on \`${cronExpr}\`.`, ephemeral: true })
}
async function handleOnce(interaction) {
const channel = interaction.options.getChannel('channel', true)
const inInput = interaction.options.getString('in', true)
const message = interaction.options.getString('message', true)
const ms = parseDuration(inInput)
if (!ms) {
await interaction.reply({ content: 'Invalid time — use a number plus s/m/h/d, e.g. `30m`, `2h`, `1d`.', ephemeral: true })
return
}
const runAt = new Date(Date.now() + ms)
const id = await scheduledMessages.addOnce({
guildId: interaction.guildId,
channelId: channel.id,
content: message,
runAt,
createdBy: interaction.user.id,
createdByTag: interaction.user.tag,
})
await interaction.reply({ content: `Scheduled one-off message #${id} in ${channel} for ${runAt.toLocaleString()}.`, ephemeral: true })
}
async function handleRemove(interaction) {
const id = interaction.options.getInteger('id', true)
const removed = await scheduledMessages.remove(interaction.guildId, id)
await scheduler.refresh()
await interaction.reply({ content: removed ? `Removed scheduled message #${id}.` : `No scheduled message #${id} found.`, ephemeral: true })
}
async function handleList(interaction) {
const rows = await scheduledMessages.list(interaction.guildId)
if (rows.length === 0) {
await interaction.reply({ content: 'No scheduled messages.', ephemeral: true })
return
}
const lines = rows.map((r) => {
let kind
if (r.cron_expression) kind = `cron \`${r.cron_expression}\``
else if (r.sent_at) kind = `sent ${new Date(r.sent_at).toLocaleString()}`
else kind = `due ${new Date(r.run_at).toLocaleString()}`
const suffix = r.enabled ? '' : ' (disabled)'
return `**#${r.id}** <#${r.channel_id}> — ${kind}${suffix}`
})
await interaction.reply({ content: lines.join('\n'), ephemeral: true })
}
module.exports = { module.exports = {
data: { data: {
name: 'schedule', name: 'schedule',
@@ -47,73 +113,9 @@ module.exports = {
}, },
async execute(interaction) { async execute(interaction) {
const sub = interaction.options.getSubcommand() const sub = interaction.options.getSubcommand()
if (sub === 'recurring') return handleRecurring(interaction)
if (sub === 'recurring') { if (sub === 'once') return handleOnce(interaction)
const channel = interaction.options.getChannel('channel', true) if (sub === 'remove') return handleRemove(interaction)
const cronExpr = interaction.options.getString('cron', true) if (sub === 'list') return handleList(interaction)
const message = interaction.options.getString('message', true)
if (!cron.validate(cronExpr)) {
await interaction.reply({ content: `"${cronExpr}" isn't a valid cron expression.`, ephemeral: true })
return
}
const id = await scheduledMessages.addRecurring({
guildId: interaction.guildId,
channelId: channel.id,
content: message,
cronExpression: cronExpr,
createdBy: interaction.user.id,
createdByTag: interaction.user.tag,
})
await scheduler.refresh()
await interaction.reply({ content: `Scheduled recurring message #${id} in ${channel} on \`${cronExpr}\`.`, ephemeral: true })
return
}
if (sub === 'once') {
const channel = interaction.options.getChannel('channel', true)
const inInput = interaction.options.getString('in', true)
const message = interaction.options.getString('message', true)
const ms = parseDuration(inInput)
if (!ms) {
await interaction.reply({ content: 'Invalid time — use a number plus s/m/h/d, e.g. `30m`, `2h`, `1d`.', ephemeral: true })
return
}
const runAt = new Date(Date.now() + ms)
const id = await scheduledMessages.addOnce({
guildId: interaction.guildId,
channelId: channel.id,
content: message,
runAt,
createdBy: interaction.user.id,
createdByTag: interaction.user.tag,
})
await interaction.reply({ content: `Scheduled one-off message #${id} in ${channel} for ${runAt.toLocaleString()}.`, ephemeral: true })
return
}
if (sub === 'remove') {
const id = interaction.options.getInteger('id', true)
const removed = await scheduledMessages.remove(interaction.guildId, id)
await scheduler.refresh()
await interaction.reply({ content: removed ? `Removed scheduled message #${id}.` : `No scheduled message #${id} found.`, ephemeral: true })
return
}
if (sub === 'list') {
const rows = await scheduledMessages.list(interaction.guildId)
if (rows.length === 0) {
await interaction.reply({ content: 'No scheduled messages.', ephemeral: true })
return
}
const lines = rows.map((r) => {
const kind = r.cron_expression
? `cron \`${r.cron_expression}\``
: r.sent_at
? `sent ${new Date(r.sent_at).toLocaleString()}`
: `due ${new Date(r.run_at).toLocaleString()}`
return `**#${r.id}** <#${r.channel_id}> — ${kind}${r.enabled ? '' : ' (disabled)'}`
})
await interaction.reply({ content: lines.join('\n'), ephemeral: true })
}
}, },
} }

View File

@@ -50,6 +50,42 @@ async function stop() {
log.info('discord client disconnected') log.info('discord client disconnected')
} }
// Post-login startup: register commands and start the background workers. A
// failure here leaves the client connected but flags an error status.
async function onReady() {
try {
await registerCommands(client.application.id, guildId)
await scheduler.start(client)
tempRoleSweeper.start(client)
inviteScheduler.start(client, guildId)
await inviteTracker.prime(client, guildId)
status = 'connected'
statusDetail = null
lastConnectedAt = new Date()
log.info('discord client ready', { user: client.user?.tag, guildId })
} catch (err) {
status = 'error'
statusDetail = `startup failed: ${err.message}`
log.error('post-login startup failed (commands/scheduler/temp-roles/invites)', { message: err.message })
}
}
// Route an interaction: role-menu handler first, then chat-input slash commands.
async function onInteractionCreate(interaction) {
if (await roleMenuHandler.handleInteraction(interaction)) return
if (!interaction.isChatInputCommand()) return
const command = commands.get(interaction.commandName)
if (!command) return
try {
await command.execute(interaction)
} catch (err) {
log.error('command execution failed', { command: interaction.commandName, message: err.message })
const payload = { content: 'Something went wrong running that command.', ephemeral: true }
if (interaction.replied || interaction.deferred) await interaction.followUp(payload)
else await interaction.reply(payload)
}
}
// start({ token, guildId }) — (re)connects. Always stops any existing client // start({ token, guildId }) — (re)connects. Always stops any existing client
// first so re-saving config or toggling Enabled off/on is idempotent. // first so re-saving config or toggling Enabled off/on is idempotent.
async function start({ token, guildId: gid }) { async function start({ token, guildId: gid }) {
@@ -72,39 +108,8 @@ async function start({ token, guildId: gid }) {
], ],
}) })
client.once('ready', async () => { client.once('ready', onReady)
try { client.on('interactionCreate', onInteractionCreate)
await registerCommands(client.application.id, guildId)
await scheduler.start(client)
tempRoleSweeper.start(client)
inviteScheduler.start(client, guildId)
await inviteTracker.prime(client, guildId)
status = 'connected'
statusDetail = null
lastConnectedAt = new Date()
log.info('discord client ready', { user: client.user?.tag, guildId })
} catch (err) {
status = 'error'
statusDetail = `startup failed: ${err.message}`
log.error('post-login startup failed (commands/scheduler/temp-roles/invites)', { message: err.message })
}
})
client.on('interactionCreate', async (interaction) => {
if (await roleMenuHandler.handleInteraction(interaction)) return
if (!interaction.isChatInputCommand()) return
const command = commands.get(interaction.commandName)
if (!command) return
try {
await command.execute(interaction)
} catch (err) {
log.error('command execution failed', { command: interaction.commandName, message: err.message })
const payload = { content: 'Something went wrong running that command.', ephemeral: true }
if (interaction.replied || interaction.deferred) await interaction.followUp(payload)
else await interaction.reply(payload)
}
})
client.on('messageCreate', messageFilter.handleMessageCreate) client.on('messageCreate', messageFilter.handleMessageCreate)
client.on('guildMemberAdd', handleGuildMemberAdd) client.on('guildMemberAdd', handleGuildMemberAdd)
client.on('guildMemberRemove', handleGuildMemberRemove) client.on('guildMemberRemove', handleGuildMemberRemove)

View File

@@ -66,8 +66,7 @@ function detectSpam(message) {
async function isBypassed(message, cache) { async function isBypassed(message, cache) {
if (cache.allowChannels.has(message.channelId)) return true if (cache.allowChannels.has(message.channelId)) return true
const memberRoles = message.member ? message.member.roles.cache : null const memberRoles = message.member ? message.member.roles.cache : null
if (memberRoles && [...memberRoles.keys()].some((id) => cache.allowRoles.has(id))) return true return Boolean(memberRoles && [...memberRoles.keys()].some((id) => cache.allowRoles.has(id)))
return false
} }
async function applyWarnAction(message, reason) { async function applyWarnAction(message, reason) {

View File

@@ -2,7 +2,7 @@
// current guild (anti-raid/anti-advertising). An invite that fails to resolve // current guild (anti-raid/anti-advertising). An invite that fails to resolve
// (expired/invalid/vanity-only) is treated as foreign too — safer default // (expired/invalid/vanity-only) is treated as foreign too — safer default
// than silently letting an unresolvable link through. // than silently letting an unresolvable link through.
const INVITE_REGEX = /(?:discord\.gg|discord(?:app)?\.com\/invite)\/([a-zA-Z0-9-]+)/gi const INVITE_REGEX = /(?:discord\.gg|discord(?:app)?\.com\/invite)\/([a-z0-9-]+)/gi
// Returns the first foreign (or unresolvable) invite code found in the message, // Returns the first foreign (or unresolvable) invite code found in the message,
// or null if the message contains no foreign invites. Returning the code (rather // or null if the message contains no foreign invites. Returning the code (rather

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,7 @@ const MAX_TIMEOUT_MS = 28 * 86_400_000
function parseDuration(input) { function parseDuration(input) {
if (!input) return null if (!input) return null
const match = /^(\d+)\s*(s|m|h|d)$/i.exec(input.trim()) const match = /^(\d+)\s*([smhd])$/i.exec(input.trim())
if (!match) return null if (!match) return null
const [, amount, unit] = match const [, amount, unit] = match
return Number(amount) * UNIT_MS[unit.toLowerCase()] return Number(amount) * UNIT_MS[unit.toLowerCase()]

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

@@ -22,6 +22,12 @@ import ChampSpawns from './routes/public/ChampSpawns.jsx'
import Guilds from './routes/public/Guilds.jsx' import Guilds from './routes/public/Guilds.jsx'
import Governors from './routes/public/Governors.jsx' import Governors from './routes/public/Governors.jsx'
import Houses from './routes/public/Houses.jsx' import Houses from './routes/public/Houses.jsx'
import Rules from './routes/public/Rules.jsx'
import Atlas from './routes/public/Atlas.jsx'
import AtlasCreature from './routes/public/AtlasCreature.jsx'
import Leaderboards from './routes/public/Leaderboards.jsx'
import Market from './routes/public/Market.jsx'
import MarketVendor from './routes/public/MarketVendor.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,11 +41,15 @@ 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 ShardAdmin from './routes/admin/views/ShardAdmin.jsx'
import ShardVisibility from './routes/admin/views/ShardVisibility.jsx'
import SpawnAtlasAdmin from './routes/admin/views/SpawnAtlas.jsx'
import ShardOps from './routes/admin/views/ShardOps.jsx' import ShardOps from './routes/admin/views/ShardOps.jsx'
import AdminCharacters from './routes/admin/views/AdminCharacters.jsx' import AdminCharacters from './routes/admin/views/AdminCharacters.jsx'
import AdminCharacter from './routes/admin/views/AdminCharacter.jsx' import AdminCharacter from './routes/admin/views/AdminCharacter.jsx'
@@ -97,6 +107,12 @@ export default function App() {
<Route path="/site/guilds" element={<Guilds />} /> <Route path="/site/guilds" element={<Guilds />} />
<Route path="/site/governors" element={<Governors />} /> <Route path="/site/governors" element={<Governors />} />
<Route path="/site/houses" element={<Houses />} /> <Route path="/site/houses" element={<Houses />} />
<Route path="/site/rules" element={<Rules />} />
<Route path="/site/atlas" element={<Atlas />} />
<Route path="/site/atlas/:slug" element={<AtlasCreature />} />
<Route path="/site/leaderboards" element={<Leaderboards />} />
<Route path="/site/market" element={<Market />} />
<Route path="/site/market/vendors/:serial" element={<MarketVendor />} />
<Route path="/wiki" element={<Wiki />} /> <Route path="/wiki" element={<Wiki />} />
<Route path="/wiki/:slug" element={<WikiArticle />} /> <Route path="/wiki/:slug" element={<WikiArticle />} />
{/* CMS pages: top-level /:slug, matched only after the named routes {/* CMS pages: top-level /:slug, matched only after the named routes
@@ -125,6 +141,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"
@@ -142,6 +180,8 @@ export default function App() {
<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" element={<ShardAdmin />} />
<Route path="shard-visibility" element={<ShardVisibility />} />
<Route path="shard-atlas" element={<SpawnAtlasAdmin />} />
<Route <Route
path="shard-ops" path="shard-ops"
element={ element={

View File

@@ -2,6 +2,10 @@
// same-origin API (/api/v1) — proxied to the Express server in dev. // same-origin API (/api/v1) — proxied to the Express server in dev.
const BASE = '/api/v1' const BASE = '/api/v1'
// Prefix a non-empty query string with "?" (and nothing when it is empty), so
// callers can append it to a path without a dangling "?".
const withQs = (s) => (s ? `?${s}` : '')
class ApiError extends Error { class ApiError extends Error {
constructor(status, message, body) { constructor(status, message, body) {
super(message) super(message)
@@ -52,8 +56,12 @@ export const api = {
getInvite: (token) => req(`/auth/invite/${encodeURIComponent(token)}`), getInvite: (token) => req(`/auth/invite/${encodeURIComponent(token)}`),
acceptInvite: (token, username, password, extra = {}) => acceptInvite: (token, username, password, extra = {}) =>
req(`/auth/invite/${encodeURIComponent(token)}/accept`, { method: 'POST', body: { username, password, ...extra } }), req(`/auth/invite/${encodeURIComponent(token)}/accept`, { method: 'POST', body: { username, password, ...extra } }),
loginTotp: (challenge, code) => // Second factor for web login. `extra` carries the optional recoveryCode (an
req('/auth/login/totp', { method: 'POST', body: { challenge, code } }), // alternative to code) and the trustDevice/deviceName opt-in. On success the
// response may include { trustLimitReached, devices } when trust was requested
// but the device cap is reached.
loginTotp: (challenge, code, extra = {}) =>
req('/auth/login/totp', { method: 'POST', body: { challenge, code, ...extra } }),
// Self-service password reset (public, token-gated). forgot always resolves the // Self-service password reset (public, token-gated). forgot always resolves the
// same way whether or not the email exists (no enumeration); getPasswordReset // same way whether or not the email exists (no enumeration); getPasswordReset
// validates a link (200 → { username }, 404 → invalid/expired); resetPassword // validates a link (200 → { username }, 404 → invalid/expired); resetPassword
@@ -63,8 +71,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'),
@@ -72,6 +82,28 @@ export const api = {
// List the active ones and revoke a single device by its session id. // List the active ones and revoke a single device by its session id.
mySessions: () => req('/auth/me/sessions'), mySessions: () => req('/auth/me/sessions'),
revokeMySession: (id) => req(`/auth/me/sessions/${encodeURIComponent(id)}`, { method: 'DELETE' }), revokeMySession: (id) => req(`/auth/me/sessions/${encodeURIComponent(id)}`, { method: 'DELETE' }),
// Trusted devices (MFA "Trust this device"), role-agnostic under /auth/me. These
// are the browsers/apps allowed to skip the TOTP step at login (distinct from
// mySessions, which are live mobile login sessions).
myTrustedDevices: () => req('/auth/me/trusted-devices'),
trustThisDevice: (deviceName) =>
req('/auth/me/trusted-devices', { method: 'POST', body: { deviceName } }),
revokeTrustedDevice: (id) =>
req(`/auth/me/trusted-devices/${encodeURIComponent(id)}`, { method: 'DELETE' }),
revokeAllTrustedDevices: () => req('/auth/me/trusted-devices', { method: 'DELETE' }),
// Recovery (backup) codes. status → remaining count; generate → a fresh set,
// returned ONCE (password step-up for accounts that have a password).
recoveryCodesStatus: () => req('/auth/me/account/recovery-codes/status'),
generateRecoveryCodes: (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'),
@@ -84,7 +116,7 @@ export const api = {
if (opts.tag) qs.set('tag', opts.tag) if (opts.tag) qs.set('tag', opts.tag)
if (opts.q) qs.set('q', opts.q) if (opts.q) qs.set('q', opts.q)
const s = qs.toString() const s = qs.toString()
return req(`/public/wiki${s ? `?${s}` : ''}`) return req(`/public/wiki${withQs(s)}`)
}, },
wikiCategories: () => req('/public/wiki/categories'), wikiCategories: () => req('/public/wiki/categories'),
wikiTags: () => req('/public/wiki/tags'), wikiTags: () => req('/public/wiki/tags'),
@@ -105,19 +137,93 @@ export const api = {
if (opts.kind) qs.set('kind', opts.kind) if (opts.kind) qs.set('kind', opts.kind)
if (opts.limit) qs.set('limit', opts.limit) if (opts.limit) qs.set('limit', opts.limit)
const s = qs.toString() const s = qs.toString()
return req(`/public/shard/feed${s ? `?${s}` : ''}`) return req(`/public/shard/feed${withQs(s)}`)
},
economy: (limit) => {
const q = limit ? `limit=${limit}` : ''
return req(`/public/shard/economy${withQs(q)}`)
}, },
economy: (limit) => req(`/public/shard/economy${limit ? `?limit=${limit}` : ''}`),
online: () => req('/public/shard/online'), online: () => req('/public/shard/online'),
idoc: () => req('/public/shard/idoc'), idoc: () => req('/public/shard/idoc'),
champs: () => req('/public/shard/champs'), champs: () => req('/public/shard/champs'),
// Protocol 2.0 boards. // Protocol 2.0 boards.
guilds: () => req('/public/shard/guilds'), guilds: () => req('/public/shard/guilds'),
governors: () => req('/public/shard/governors'), governors: () => req('/public/shard/governors'),
governorHistory: (city, limit) => governorHistory: (city, limit) => {
req(`/public/shard/governors/${encodeURIComponent(city)}/history${limit ? `?limit=${limit}` : ''}`), const q = limit ? `limit=${limit}` : ''
return req(`/public/shard/governors/${encodeURIComponent(city)}/history${withQs(q)}`)
},
presence: () => req('/public/shard/presence'), presence: () => req('/public/shard/presence'),
houses: () => req('/public/shard/houses'), houses: () => req('/public/shard/houses'),
// Protocol 3.0: the shard's published ruleset. Resolves to null when the
// shard has never published one — a real answer, not an error.
ruleset: () => req('/public/shard/ruleset'),
// Protocol 3.0: points/loyalty leaderboards, one board per point system.
// `board` 404s for a system the shard has never published.
points: () => req('/public/shard/points'),
pointsBoard: (system) => req(`/public/shard/points/${encodeURIComponent(system)}`),
// Protocol 3.0: the player-vendor marketplace. Rate-limited server-side, so
// the page debounces its search box rather than firing per keystroke.
market: (opts = {}) => {
const qs = new URLSearchParams()
if (opts.q) qs.set('q', opts.q)
if (opts.minPrice != null && opts.minPrice !== '') qs.set('minPrice', opts.minPrice)
if (opts.maxPrice != null && opts.maxPrice !== '') qs.set('maxPrice', opts.maxPrice)
if (opts.itemId != null && opts.itemId !== '') qs.set('itemId', opts.itemId)
if (opts.map) qs.set('map', opts.map)
if (opts.region) qs.set('region', opts.region)
if (opts.sort) qs.set('sort', opts.sort)
if (opts.limit) qs.set('limit', opts.limit)
if (opts.offset) qs.set('offset', opts.offset)
return req(`/public/shard/market${withQs(qs.toString())}`)
},
marketMeta: () => req('/public/shard/market/meta'),
marketVendor: (serial, opts = {}) => {
const qs = new URLSearchParams()
if (opts.limit) qs.set('limit', opts.limit)
if (opts.offset) qs.set('offset', opts.offset)
return req(`/public/shard/market/vendors/${encodeURIComponent(serial)}${withQs(qs.toString())}`)
},
// Which shard surfaces this caller may reach, plus the audience rung they
// resolved to. Drives nav so we never render a link that would 403.
features: () => req('/public/shard/features'),
},
// ----- spawn atlas (Protocol 3.0 Part C) -----
// Static shard CONTENT, parsed from the shard's own ServUO tree — deliberately
// not under /shard, because nothing here depends on the sidecar and the pages
// stay populated while the shard is offline.
atlas: {
creatures: (opts = {}) => {
const qs = new URLSearchParams()
if (opts.q) qs.set('q', opts.q)
if (opts.facet) qs.set('facet', opts.facet)
if (opts.limit) qs.set('limit', opts.limit)
if (opts.offset) qs.set('offset', opts.offset)
return req(`/public/atlas/creatures${withQs(qs.toString())}`)
},
creature: (slug, opts = {}) => {
const qs = new URLSearchParams()
if (opts.facet) qs.set('facet', opts.facet)
if (opts.points) qs.set('points', opts.points)
return req(`/public/atlas/creatures/${encodeURIComponent(slug)}${withQs(qs.toString())}`)
},
regions: (opts = {}) => {
const qs = new URLSearchParams()
if (opts.facet) qs.set('facet', opts.facet)
if (opts.q) qs.set('q', opts.q)
return req(`/public/atlas/regions${withQs(qs.toString())}`)
},
landmarks: (opts = {}) => {
const qs = new URLSearchParams()
if (opts.facet) qs.set('facet', opts.facet)
if (opts.q) qs.set('q', opts.q)
return req(`/public/atlas/landmarks${withQs(qs.toString())}`)
},
// The CONFIGURED altar roster, not the live board — see shard.champs() for
// "which spawn is on level 3 right now".
champions: (facet) => req(`/public/atlas/champions${withQs(facet ? `facet=${encodeURIComponent(facet)}` : '')}`),
meta: () => req('/public/atlas/meta'),
}, },
// Full paths (incl. /api/v1) for the browser EventSource — the req() wrapper is // 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 // fetch-only, so SSE subscribers build the URL from here. The admin stream
@@ -129,7 +235,10 @@ export const api = {
admin: { admin: {
dashboard: () => req('/admin/dashboard'), dashboard: () => req('/admin/dashboard'),
setSiteMode: (mode) => req('/admin/site-mode', { method: 'PUT', body: { mode } }), setSiteMode: (mode) => req('/admin/site-mode', { method: 'PUT', body: { mode } }),
listPosts: (category) => req(`/admin/posts${category ? `?category=${category}` : ''}`), listPosts: (category) => {
const q = category ? `category=${category}` : ''
return req(`/admin/posts${withQs(q)}`)
},
getPost: (id) => req(`/admin/posts/${id}`), getPost: (id) => req(`/admin/posts/${id}`),
createPost: (data) => req('/admin/posts', { method: 'POST', body: data }), createPost: (data) => req('/admin/posts', { method: 'POST', body: data }),
updatePost: (id, data) => req(`/admin/posts/${id}`, { method: 'PUT', body: data }), updatePost: (id, data) => req(`/admin/posts/${id}`, { method: 'PUT', body: data }),
@@ -179,6 +288,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 } }),
@@ -187,6 +310,13 @@ export const api = {
createUser: (data) => req('/admin/users', { method: 'POST', body: data }), createUser: (data) => req('/admin/users', { method: 'POST', body: data }),
updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }), updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }),
deleteUser: (id) => req(`/admin/users/${id}`, { method: 'DELETE' }), deleteUser: (id) => req(`/admin/users/${id}`, { method: 'DELETE' }),
// A user's trusted devices + MFA reset (admin only).
userTrustedDevices: (id) => req(`/admin/users/${id}/trusted-devices`),
revokeUserTrustedDevice: (id, deviceId) =>
req(`/admin/users/${id}/trusted-devices/${deviceId}`, { method: 'DELETE' }),
revokeAllUserTrustedDevices: (id) =>
req(`/admin/users/${id}/trusted-devices`, { method: 'DELETE' }),
resetUserMfa: (id) => req(`/admin/users/${id}/mfa/reset`, { method: 'POST' }),
// Email invites. // Email invites.
listInvites: () => req('/admin/invites'), listInvites: () => req('/admin/invites'),
createInvite: (email, role, sendEmail = true) => createInvite: (email, role, sendEmail = true) =>
@@ -216,7 +346,7 @@ export const api = {
if (params.limit) qs.set('limit', params.limit) if (params.limit) qs.set('limit', params.limit)
if (params.offset) qs.set('offset', params.offset) if (params.offset) qs.set('offset', params.offset)
const s = qs.toString() const s = qs.toString()
return req(`/admin/moderation/recent${s ? `?${s}` : ''}`) return req(`/admin/moderation/recent${withQs(s)}`)
}, },
modSearch: (q) => req(`/admin/moderation/search?q=${encodeURIComponent(q)}`), modSearch: (q) => req(`/admin/moderation/search?q=${encodeURIComponent(q)}`),
modMembers: (params = {}) => { modMembers: (params = {}) => {
@@ -225,21 +355,21 @@ export const api = {
if (params.limit) qs.set('limit', params.limit) if (params.limit) qs.set('limit', params.limit)
if (params.offset) qs.set('offset', params.offset) if (params.offset) qs.set('offset', params.offset)
const s = qs.toString() const s = qs.toString()
return req(`/admin/moderation/members${s ? `?${s}` : ''}`) return req(`/admin/moderation/members${withQs(s)}`)
}, },
modFilterHits: (params = {}) => { modFilterHits: (params = {}) => {
const qs = new URLSearchParams() const qs = new URLSearchParams()
if (params.limit) qs.set('limit', params.limit) if (params.limit) qs.set('limit', params.limit)
if (params.offset) qs.set('offset', params.offset) if (params.offset) qs.set('offset', params.offset)
const s = qs.toString() const s = qs.toString()
return req(`/admin/moderation/filter-hits${s ? `?${s}` : ''}`) return req(`/admin/moderation/filter-hits${withQs(s)}`)
}, },
modSpamHits: (params = {}) => { modSpamHits: (params = {}) => {
const qs = new URLSearchParams() const qs = new URLSearchParams()
if (params.limit) qs.set('limit', params.limit) if (params.limit) qs.set('limit', params.limit)
if (params.offset) qs.set('offset', params.offset) if (params.offset) qs.set('offset', params.offset)
const s = qs.toString() const s = qs.toString()
return req(`/admin/moderation/spam-hits${s ? `?${s}` : ''}`) return req(`/admin/moderation/spam-hits${withQs(s)}`)
}, },
modUser: (discordId) => req(`/admin/moderation/user/${discordId}`), modUser: (discordId) => req(`/admin/moderation/user/${discordId}`),
modUserActions: (discordId, params = {}) => { modUserActions: (discordId, params = {}) => {
@@ -248,7 +378,7 @@ export const api = {
if (params.limit) qs.set('limit', params.limit) if (params.limit) qs.set('limit', params.limit)
if (params.offset) qs.set('offset', params.offset) if (params.offset) qs.set('offset', params.offset)
const s = qs.toString() const s = qs.toString()
return req(`/admin/moderation/user/${discordId}/actions${s ? `?${s}` : ''}`) return req(`/admin/moderation/user/${discordId}/actions${withQs(s)}`)
}, },
modUserNotes: (discordId) => req(`/admin/moderation/user/${discordId}/notes`), modUserNotes: (discordId) => req(`/admin/moderation/user/${discordId}/notes`),
addModNote: (discordId, data) => addModNote: (discordId, data) =>
@@ -261,7 +391,7 @@ export const api = {
if (params.limit) qs.set('limit', params.limit) if (params.limit) qs.set('limit', params.limit)
if (params.offset) qs.set('offset', params.offset) if (params.offset) qs.set('offset', params.offset)
const s = qs.toString() const s = qs.toString()
return req(`/admin/moderation/appeals${s ? `?${s}` : ''}`) return req(`/admin/moderation/appeals${withQs(s)}`)
}, },
getAppeal: (id) => req(`/admin/moderation/appeals/${id}`), getAppeal: (id) => req(`/admin/moderation/appeals/${id}`),
claimAppeal: (id) => req(`/admin/moderation/appeals/${id}/claim`, { method: 'POST' }), claimAppeal: (id) => req(`/admin/moderation/appeals/${id}/claim`, { method: 'POST' }),
@@ -307,6 +437,25 @@ export const api = {
saveUoLinkConfig: (data) => req('/admin/uo-link/config', { method: 'PUT', body: data }), saveUoLinkConfig: (data) => req('/admin/uo-link/config', { method: 'PUT', body: data }),
postTownCrier: (data) => req('/admin/uo-link/towncrier', { method: 'POST', body: data }), postTownCrier: (data) => req('/admin/uo-link/towncrier', { method: 'POST', body: data }),
deleteTownCrier: (id) => req(`/admin/uo-link/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' }), deleteTownCrier: (id) => req(`/admin/uo-link/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' }),
// Per-feature shard visibility: who may see which shard surface, and which
// sensitive fields within it. Admin only — it decides what ANONYMOUS
// visitors get. acct/webId are admin-only always and the API rejects any
// attempt to configure them.
getShardVisibility: () => req('/admin/shard/visibility'),
saveShardVisibility: (features) =>
req('/admin/shard/visibility', { method: 'PUT', body: { features } }),
// ----- spawn atlas operation (admin only) -----
// The atlas re-derives itself from the ServUO tree on every boot; these are
// for applying a map change without a restart, and for the approve/reject
// decision on a refresh that would remove a facet.
atlas: {
status: () => req('/admin/shard/atlas'),
import: (force = false) => req('/admin/shard/atlas/import', { method: 'POST', body: { force } }),
approve: () => req('/admin/shard/atlas/approve', { method: 'POST', body: {} }),
reject: () => req('/admin/shard/atlas/reject', { method: 'POST', body: {} }),
setPath: (path) => req('/admin/shard/atlas/path', { method: 'PUT', body: { path } }),
},
// ----- in-game staff operations: write plane + support queue (admin/moderator) ----- // ----- in-game staff operations: write plane + support queue (admin/moderator) -----
// `actor` is stamped server-side from the session — never sent from here. // `actor` is stamped server-side from the session — never sent from here.

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

@@ -10,24 +10,89 @@ import ShardAccountActions from './ShardAccountActions.jsx'
const RESIST_LABELS = { phys: 'Physical', fire: 'Fire', cold: 'Cold', pois: 'Poison', energy: 'Energy' } const RESIST_LABELS = { phys: 'Physical', fire: 'Fire', cold: 'Cold', pois: 'Poison', energy: 'Energy' }
// What to call an equipped item.
//
// Items on the wire carry a `LabelNumber`, not a name, so this used to be able
// to show nothing but the layer and `id 12345`. The server now resolves the
// cliloc against its own table and attaches `clilocName` (see
// docs/website/CLILOCS.md); a shard with no cliloc file configured sends none,
// and the layer fallback below is exactly what the sheet did before.
//
// A player-given `name` outranks the resolved type name — "Bob's lucky axe"
// should not be relabelled "hatchet" — and the server applies the same
// precedence, so this only re-states it for a profile that arrived with both.
const itemName = (it) => it.name || it.clilocName || it.layer || 'Item'
// The char.profile `titles` block (Protocol 2.0). fameKarma/skill are already // 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 // 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 // literal string.
// numeric reward entries are skipped rather than shown as a raw number. Returns a //
// de-duped list of human-readable title chips. // `rewardResolved` is the server's parallel array with the numeric entries turned
// into words (null where the cliloc table had nothing, or is not configured at
// all). Prefer it, and keep the literal-only path as the fallback for a profile
// served before the cliloc table existed — a numeric entry with no resolution is
// still skipped rather than shown as a raw number.
function displayTitles(titles) { function displayTitles(titles) {
if (!titles) return [] if (!titles) return []
const out = [] const out = []
if (titles.fameKarma) out.push(titles.fameKarma) if (titles.fameKarma) out.push(titles.fameKarma)
if (titles.skill) out.push(titles.skill) if (titles.skill) out.push(titles.skill)
const reward = Array.isArray(titles.reward) ? titles.reward : [] const raw = Array.isArray(titles.reward) ? titles.reward : []
const resolved = Array.isArray(titles.rewardResolved) ? titles.rewardResolved : null
const reward = raw.map((r, i) => resolved?.[i] ?? (/^\d+$/.test(String(r)) ? null : String(r)))
const sel = typeof titles.selected === 'number' ? titles.selected : -1 const sel = typeof titles.selected === 'number' ? titles.selected : -1
// Prefer the selected reward title; fall back to the first literal one. // Prefer the selected reward title; fall back to the first one that resolved.
const candidate = sel >= 0 && sel < reward.length ? reward[sel] : reward.find((r) => r && !/^\d+$/.test(String(r))) // The `??` matters: a selected title whose cliloc did not resolve must fall
if (candidate && !/^\d+$/.test(String(candidate))) out.push(String(candidate)) // through to the fallback rather than suppress the chip entirely.
const candidate = (sel >= 0 && sel < reward.length ? reward[sel] : null) ?? reward.find(Boolean)
if (candidate) out.push(String(candidate))
return [...new Set(out.filter(Boolean))] return [...new Set(out.filter(Boolean))]
} }
// The char.profile `points` block (Protocol 3.0 §7.3): one entry per point system
// the character actually holds a score in. Systems at zero are omitted by the
// shard, so an empty list means "this character has earned nothing anywhere",
// which is a normal state for a new character and renders as nothing at all.
//
// `nameString` may be null when the system's name is a cliloc; fall back to
// humanising the PointsType key, exactly as the leaderboards page does. `rank` is
// absent unless the shard runs with Bridge.cfg PointsProfileRank=true — absent and
// "unranked" are different, so the chip only appears when it was actually sent.
const humanisePoints = (key) =>
String(key || '')
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/^./, (c) => c.toUpperCase())
function PointsRow({ entry }) {
const label = entry.nameString || humanisePoints(entry.system)
const max = Number.isFinite(entry.maxPoints) && entry.maxPoints > 0 ? entry.maxPoints : 0
const pct = max ? Math.min(100, Math.round((entry.points / max) * 100)) : 0
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 3, gap: 10 }}>
<span className="sans" style={{ color: 'var(--ink)', fontSize: '0.86rem' }}>
{label}
{Number.isFinite(entry.rank) && (
<span className="dim" style={{ fontSize: '0.74rem' }}> · #{entry.rank}</span>
)}
</span>
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.82rem', flex: 'none' }}>
{(entry.points ?? 0).toLocaleString()}
{max > 0 && <span className="dim"> / {max.toLocaleString()}</span>}
</span>
</div>
{/* Only systems with a real cap get a bar; an uncapped score has nothing to
be a fraction of, and a full-width bar would imply completion. */}
{max > 0 && (
<div style={{ height: 4, borderRadius: 999, background: 'var(--line)', overflow: 'hidden' }}>
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
</div>
)}
</div>
)
}
function TitleChip({ children, tone = 'var(--muted)' }) { function TitleChip({ children, tone = 'var(--muted)' }) {
return ( return (
<span <span
@@ -75,6 +140,11 @@ export default function CharacterSheet({ char, moderation = false }) {
.filter((s) => (s.value || s.base || 0) > 0) .filter((s) => (s.value || s.base || 0) > 0)
.sort((a, b) => (b.value || 0) - (a.value || 0)) .sort((a, b) => (b.value || 0) - (a.value || 0))
const equipment = char.equipment || [] const equipment = char.equipment || []
// Best standing first, so the character's strongest loyalty leads. Guarded for
// an older shard plugin that sends no `points` block at all.
const points = (Array.isArray(char.points) ? char.points : [])
.filter((p) => p && (p.points || 0) > 0)
.sort((a, b) => (b.points || 0) - (a.points || 0))
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 22 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 22 }}>
@@ -173,17 +243,37 @@ export default function CharacterSheet({ char, moderation = false }) {
</section> </section>
)} )}
{/* Loyalty & points — one entry per system this character has scored in */}
{points.length > 0 && (
<section>
<div className="field-label" style={{ marginBottom: 8 }}>
Loyalty &amp; points <span className="dim">({points.length})</span>
</div>
<div className="grid-2" style={{ gap: '8px 18px' }}>
{points.map((p) => (
<PointsRow key={p.system} entry={p} />
))}
</div>
</section>
)}
{/* Equipment */} {/* Equipment */}
{equipment.length > 0 && ( {equipment.length > 0 && (
<section> <section>
<div className="field-label" style={{ marginBottom: 8 }}>Equipment</div> <div className="field-label" style={{ marginBottom: 8 }}>Equipment</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{equipment.map((it) => ( {equipment.map((it) => {
const label = itemName(it)
const layer = it.layer || 'Item'
// The layer only earns its own line once the headline is a real
// name; when it IS the headline, repeating it is just noise.
const detail = [label === layer ? null : layer, `id ${it.itemId}`, it.hue ? `hue ${it.hue}` : null]
return (
<div key={it.serial} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}> <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)' }} /> <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 style={{ flex: 1, minWidth: 0 }}>
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.88rem' }}>{it.layer || 'Item'}</div> <div className="sans" style={{ color: 'var(--head)', fontSize: '0.88rem' }}>{label}</div>
<div className="sans dim" style={{ fontSize: '0.74rem' }}>id {it.itemId}{it.hue ? ` · hue ${it.hue}` : ''}</div> <div className="sans dim" style={{ fontSize: '0.74rem' }}>{detail.filter(Boolean).join(' · ')}</div>
</div> </div>
{it.mods && Object.keys(it.mods).length > 0 && ( {it.mods && Object.keys(it.mods).length > 0 && (
<div className="sans" style={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'flex-end', maxWidth: '55%' }}> <div className="sans" style={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'flex-end', maxWidth: '55%' }}>
@@ -193,7 +283,8 @@ export default function CharacterSheet({ char, moderation = false }) {
</div> </div>
)} )}
</div> </div>
))} )
})}
</div> </div>
</section> </section>
)} )}

View File

@@ -20,6 +20,25 @@ function Tile({ value, label }) {
) )
} }
// 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 }) { export default function CharacterStats({ scope }) {
const [stats, setStats] = useState(null) const [stats, setStats] = useState(null)
@@ -36,19 +55,7 @@ export default function CharacterStats({ scope }) {
// Roster is a live round-trip and can be unavailable (503); tolerate a // 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. // partial result so a restarting shard doesn't blank the whole row.
const rosters = await Promise.allSettled(accounts.map((a) => scope.roster(a.account))) const rosters = await Promise.allSettled(accounts.map((a) => scope.roster(a.account)))
let chars = 0 if (!cancelled) setStats({ linked, ...summarizeRosters(rosters) })
let online = 0
let complete = true
for (const r of rosters) {
if (r.status === 'fulfilled') {
const cs = r.value.chars || []
chars += cs.length
online += cs.filter((c) => c.online).length
} else {
complete = false
}
}
if (!cancelled) setStats({ linked, chars, online, complete })
} catch { } catch {
if (!cancelled) setStats({ error: true }) if (!cancelled) setStats({ error: true })
} }

View File

@@ -121,7 +121,8 @@ function UnlinkButton({ account, onUnlink }) {
try { try {
await onUnlink(account) await onUnlink(account)
} catch (err) { } catch (err) {
setError(err.status === 403 ? 'Protected account — refused.' : err.status === 404 ? 'Not linked.' : (err.message || 'Could not unlink.')) const byStatus = { 403: 'Protected account — refused.', 404: 'Not linked.' }
setError(byStatus[err.status] || err.message || 'Could not unlink.')
setBusy(false) setBusy(false)
} }
} }

View File

@@ -34,14 +34,15 @@ function TextBlock({ props }) {
const align = props.align || 'center' const align = props.align || 'center'
return ( return (
<div style={{ textAlign: align, textShadow: '0 2px 22px rgba(0,0,0,0.82)' }}> <div style={{ textAlign: align, textShadow: '0 2px 22px rgba(0,0,0,0.82)' }}>
{(props.lines || []).map((line, i) => { {(props.lines || []).map((line) => {
const Tag = /^(h1|h2|h3|p|span|div)$/.test(line.tag) ? line.tag : 'p' const Tag = /^(h1|h2|h3|p|span|div)$/.test(line.tag) ? line.tag : 'p'
const key = `${line.tag}:${(line.text || '').slice(0, 40)}`
// A rich-text line (e.g. the homepage teaser) carries sanitized HTML; // A rich-text line (e.g. the homepage teaser) carries sanitized HTML;
// sanitize again on render as defense in depth. Others render as text. // sanitize again on render as defense in depth. Others render as text.
if (line.html) { if (line.html) {
return ( return (
<Tag <Tag
key={i} key={key}
className="hero-rich" className="hero-rich"
style={lineStyle(line)} style={lineStyle(line)}
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(line.text || '') }} dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(line.text || '') }}
@@ -49,7 +50,7 @@ function TextBlock({ props }) {
) )
} }
return ( return (
<Tag key={i} style={lineStyle(line)}> <Tag key={key} style={lineStyle(line)}>
{line.text} {line.text}
</Tag> </Tag>
) )
@@ -59,11 +60,11 @@ function TextBlock({ props }) {
} }
function Buttons({ props }) { function Buttons({ props }) {
const justify = props.align === 'left' ? 'flex-start' : props.align === 'right' ? 'flex-end' : 'center' const justify = { left: 'flex-start', right: 'flex-end' }[props.align] || 'center'
return ( return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: props.gap ?? 12, justifyContent: justify }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: props.gap ?? 12, justifyContent: justify }}>
{(props.items || []).map((b, i) => ( {(props.items || []).map((b) => (
<Link key={i} to={b.to || '#'} className={`btn ${b.variant === 'ghost' ? 'btn-ghost' : 'btn-primary'}`}> <Link key={`${b.to || ''}:${b.label || ''}`} to={b.to || '#'} className={`btn ${b.variant === 'ghost' ? 'btn-ghost' : 'btn-primary'}`}>
{b.label} {b.label}
</Link> </Link>
))} ))}
@@ -160,12 +161,7 @@ export default function HeroElement({
children, children,
}) { }) {
const anchor = element.anchor || 'center' const anchor = element.anchor || 'center'
const transform = const transform = { center: 'translate(-50%, -50%)', 'top-right': 'translateX(-100%)' }[anchor]
anchor === 'center'
? 'translate(-50%, -50%)'
: anchor === 'top-right'
? 'translateX(-100%)'
: undefined
// text_block/buttons may set a box width (px); kept within the containing block // text_block/buttons may set a box width (px); kept within the containing block
// (the hero section live, or the editor canvas) with small side gutters. // (the hero section live, or the editor canvas) with small side gutters.
const boxWidth = const boxWidth =

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

@@ -36,7 +36,7 @@ function AlignIcon({ align }) {
return ( return (
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true"> <svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true">
{rows.map(([x1, x2], i) => ( {rows.map(([x1, x2], i) => (
<line key={i} x1={x1} y1={4 + i * 4} x2={x2} y2={4 + i * 4} /> <line key={`${x1}-${x2}`} x1={x1} y1={4 + i * 4} x2={x2} y2={4 + i * 4} />
))} ))}
</svg> </svg>
) )

View File

@@ -36,9 +36,12 @@ export default function ShardAccountActions({ account, style }) {
} }
const kick = () => const kick = () =>
run('kick', () => api.admin.shardOps.kick({ account }), (r) => run('kick', () => api.admin.shardOps.kick({ account }), (r) => {
`Kicked${r && r.sessions != null ? ` (${r.sessions} session${r.sessions === 1 ? '' : 's'})` : ''}.`, 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 unban = () => run('unban', () => api.admin.shardOps.unban(account), () => 'Unbanned.')
const ban = () => const ban = () =>
run('ban', () => run('ban', () =>
@@ -49,7 +52,8 @@ export default function ShardAccountActions({ account, style }) {
}), }),
() => { () => {
setBanOpen(false) setBanOpen(false)
return `Banned${durationSec ? ` for ${durationSec}s` : ' indefinitely'}.` const when = durationSec ? ` for ${durationSec}s` : ' indefinitely'
return `Banned${when}.`
}) })
const btn = { fontSize: '0.72rem', padding: '4px 10px' } const btn = { fontSize: '0.72rem', padding: '4px 10px' }

View File

@@ -37,7 +37,7 @@ export default function SiteFooter() {
{contactEmail} {contactEmail}
</a> </a>
&nbsp;·&nbsp; &nbsp;·&nbsp;
<Link to="/site/status" style={{ color: 'var(--accent)', textDecoration: 'none' }}> <Link to="/site/shard" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
Shard Status Shard Status
</Link> </Link>
&nbsp;·&nbsp; &nbsp;·&nbsp;

View File

@@ -1,22 +1,41 @@
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 { useShardFeatures, canSee } from '../lib/useShardFeatures.js'
import NavDropdown from './NavDropdown.jsx'
import { buildPublicNav, pruneNav } from '../lib/navOverrides.js'
import { parseJsonSetting } from '../lib/settingsJson.js'
// 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 = [ //
// Entries carrying a `feature` are shard surfaces an admin can disable or gate
// to a higher audience (Admin -> Shard Visibility). They are hidden when this
// viewer can't reach them, so we never render a link that would 403. The gate
// itself is server-side; this is only about not advertising a dead end.
//
// 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).
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: 'Shard', to: '/site/shard', feature: 'status' },
{ label: 'Champions', to: '/site/champs' }, { label: 'Champions', to: '/site/champs', feature: 'champs' },
{ label: 'Guilds', to: '/site/guilds' }, { label: 'Guilds', to: '/site/guilds', feature: 'guilds' },
{ label: 'Governors', to: '/site/governors' }, { label: 'Governors', to: '/site/governors', feature: 'governors' },
{ label: 'Houses', to: '/site/houses' }, { label: 'Houses', to: '/site/houses', feature: 'houses' },
{ label: 'Rules', to: '/site/rules', feature: 'ruleset' },
{ label: 'Atlas', to: '/site/atlas', feature: 'atlas' },
{ label: 'Leaderboards', to: '/site/leaderboards', feature: 'leaderboards' },
{ label: 'Market', to: '/site/market', feature: 'market' },
{ label: 'About', to: '/site/about' }, { label: 'About', to: '/site/about' },
] ]
@@ -28,15 +47,30 @@ 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 shardFeatures = useShardFeatures()
// 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 shard 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(NAV, parseJsonSetting(settings.nav_public))
return pruneNav(tree, (item) => !item.feature || canSee(shardFeatures, item.feature))
}, [settings.nav_public, shardFeatures])
// Where the auth entry points: staff → admin, player → portal, else sign in. // Where the auth entry points: staff → admin, player → portal, else sign in.
const account = let account
user && user.role && user.role !== 'player' if (user && user.role && user.role !== 'player') account = { label: 'Admin', to: '/admin' }
? { label: 'Admin', to: '/admin' } else if (user) account = { label: 'My Account', to: '/player' }
: user else account = { label: 'Sign in', to: '/account/login' }
? { label: 'My Account', to: '/player' }
: { label: 'Sign in', to: '/account/login' }
return ( return (
<header <header
@@ -58,15 +92,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' ? (
{l.label} <NavDropdown key={l.id} label={l.label} items={l.items} linkStyle={linkStyle} />
</NavLink> ) : (
))} <NavLink key={l.kind === 'link' ? l.id : l.to} to={l.to} end={l.end} className="pill" style={linkStyle}>
{l.label}
</NavLink>
),
)}
{!loading && ( {!loading && (
<NavLink <NavLink
to={account.to} to={account.to}

View File

@@ -26,8 +26,8 @@ export default function VendorSales({ fetchSales }) {
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No vendor sales recorded yet.</p> <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 }}> <ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
{sales.map((s, i) => ( {sales.map((s) => (
<li key={`${s.t}-${i}`} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}> <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' }}> <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 {s.itemType || 'An item'}{s.amount > 1 ? ` ×${s.amount}` : ''} {Number(s.price || 0).toLocaleString()}gp
</span> </span>

View File

@@ -0,0 +1,62 @@
import { useState } from 'react'
// Renders a freshly generated batch of recovery codes ONCE, with copy + download.
// The backend never returns these again, so the copy stresses saving them now.
export default function RecoveryCodesDisplay({ codes, onDone }) {
const [copied, setCopied] = useState(false)
const text = (codes || []).join('\n')
async function copy() {
try {
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
/* clipboard blocked — the codes are visible to copy manually */
}
}
function download() {
const blob = new Blob([`${text}\n`], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'recovery-codes.txt'
a.click()
URL.revokeObjectURL(url)
}
return (
<div style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 18, marginTop: 8 }}>
<p className="sans" style={{ margin: '0 0 12px', color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
Save these recovery codes somewhere safe. Each can be used <strong>once</strong> to sign in if you
lose your authenticator. <strong>They will not be shown again.</strong>
</p>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))',
gap: 8,
fontFamily: 'monospace',
fontSize: '0.95rem',
marginBottom: 14,
}}
>
{(codes || []).map((c) => (
<div key={c} style={{ padding: '8px 10px', border: '1px solid var(--line-soft)', borderRadius: 6, letterSpacing: '0.06em', textAlign: 'center', color: 'var(--head)' }}>
{c}
</div>
))}
</div>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={copy} className="pill">{copied ? 'Copied!' : 'Copy'}</button>
<button onClick={download} className="pill">Download</button>
{onDone && (
<button onClick={onDone} className="btn btn-primary btn-sq" style={{ marginLeft: 'auto' }}>
Ive saved them
</button>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,86 @@
import { useCallback, useEffect, useState } from 'react'
import { api } from '../../api/client.js'
import RecoveryCodesDisplay from './RecoveryCodesDisplay.jsx'
// Self-service recovery (backup) codes. Shows how many remain and lets the user
// regenerate a fresh set (password step-up). Shown only when 2FA is enabled.
// `hasPassword` decides whether the current-password field is required — an
// SSO-only account with no password may regenerate while authenticated.
export default function RecoveryCodesPanel({ hasPassword = true }) {
const [remaining, setRemaining] = useState(null)
const [currentPassword, setCurrentPassword] = useState('')
const [codes, setCodes] = useState(null) // freshly generated batch, shown once
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const load = useCallback(async () => {
try {
const { remaining: n } = await api.recoveryCodesStatus()
setRemaining(n)
} catch {
/* non-fatal — the panel still offers regeneration */
}
}, [])
useEffect(() => {
load()
}, [load])
async function regenerate() {
setBusy(true)
setError('')
try {
const { recoveryCodes } = await api.generateRecoveryCodes(hasPassword ? currentPassword : undefined)
setCodes(recoveryCodes)
setCurrentPassword('')
await load()
} catch (err) {
setError(err.message || 'Could not generate recovery codes.')
} finally {
setBusy(false)
}
}
return (
<div style={{ marginTop: 40, borderTop: '1px solid var(--line-soft)', paddingTop: 28 }}>
<h2 className="display" style={{ marginTop: 0, fontSize: '1.2rem', color: 'var(--head)' }}>
Recovery codes
</h2>
<p className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
Single-use codes that let you sign in if you lose your authenticator. Regenerating replaces any
codes you still have.
</p>
{remaining != null && !codes && (
<p className="sans" style={{ color: remaining > 0 ? '#7fd0a4' : '#e0b352', fontSize: '0.86rem' }}>
{remaining > 0 ? `${remaining} unused code${remaining === 1 ? '' : 's'} remaining.` : 'No unused recovery codes left — regenerate a set.'}
</p>
)}
{codes ? (
<RecoveryCodesDisplay codes={codes} onDone={() => setCodes(null)} />
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginTop: 10 }}>
{hasPassword && (
<label style={{ display: 'block', maxWidth: 260 }}>
<span className="field-label">Current password</span>
<input
type="password"
autoComplete="current-password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
className="input"
/>
</label>
)}
<div>
<button onClick={regenerate} disabled={busy || (hasPassword && !currentPassword)} className="btn btn-sq">
{busy ? 'Generating…' : 'Generate new codes'}
</button>
</div>
</div>
)}
{error && <p className="sans" style={{ marginTop: 14, color: '#d98b84', fontSize: '0.86rem' }}>{error}</p>}
</div>
)
}

View File

@@ -0,0 +1,124 @@
import { useState } from 'react'
import { api } from '../../api/client.js'
// Shown when a user tries to trust a device but is already at the trusted-device
// cap. Styled like the TOTP entry flow (centered card on a dim overlay). The user
// MUST revoke at least one existing device before they can continue — there is no
// silent pruning — or they can cancel and leave the device untrusted.
//
// Props:
// devices — the existing trusted devices (from the 409 / trustLimitReached payload)
// onTrusted — called after the current device is successfully trusted (post-revoke)
// onCancel — called when the user backs out without trusting this device
export default function TrustLimitModal({ devices: initialDevices, onTrusted, onCancel }) {
const [devices, setDevices] = useState(initialDevices || [])
const [revokedAny, setRevokedAny] = useState(false)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
async function revoke(id) {
setBusy(true)
setError('')
try {
await api.revokeTrustedDevice(id)
setDevices((list) => list.filter((d) => d.id !== id))
setRevokedAny(true)
} catch {
setError('Could not revoke that device. Please try again.')
} finally {
setBusy(false)
}
}
async function trustNow() {
setBusy(true)
setError('')
try {
await api.trustThisDevice()
onTrusted?.()
} catch (err) {
// Still at the cap somehow (a race) — surface it and let them revoke more.
if (err.status === 409 && err.body?.devices) {
setDevices(err.body.devices)
setError('Still at the limit — revoke another device.')
} else {
setError('Could not trust this device. Please try again.')
}
} finally {
setBusy(false)
}
}
return (
<div style={overlay} role="dialog" aria-modal="true" aria-label="Trusted-device limit reached">
<div style={card}>
<h2 className="display" style={{ margin: '0 0 8px', fontSize: '1.15rem', color: 'var(--head)' }}>
Trusted-device limit reached
</h2>
<p className="sans" style={{ margin: '0 0 16px', color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
You can trust up to {Math.max(devices.length, 1)} devices. Revoke one below to make room, then
continue or cancel to leave this device untrusted.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16, maxHeight: 240, overflowY: 'auto' }}>
{devices.map((d) => (
<div key={d.id} style={row}>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.88rem' }}>
{d.deviceName || d.platform || 'Device'}
</div>
<div className="sans dim" style={{ fontSize: '0.74rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{d.userAgent || '—'}
</div>
</div>
<button onClick={() => revoke(d.id)} disabled={busy} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
Revoke
</button>
</div>
))}
{devices.length === 0 && (
<p className="sans dim" style={{ fontSize: '0.84rem', margin: 0 }}>All devices revoked. You can trust this one now.</p>
)}
</div>
{error && <p className="sans" style={{ margin: '0 0 12px', color: '#d98b84', fontSize: '0.84rem' }}>{error}</p>}
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<button onClick={trustNow} disabled={busy || !revokedAny} className="btn btn-primary btn-sq">
{busy ? 'Working…' : 'Trust this device'}
</button>
<button onClick={onCancel} disabled={busy} className="pill">
Cancel
</button>
</div>
</div>
</div>
)
}
const overlay = {
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.6)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
zIndex: 1000,
}
const card = {
width: '100%',
maxWidth: 460,
background: 'var(--panel, #1a1a1f)',
border: '1px solid var(--line)',
borderRadius: 12,
padding: 24,
}
const row = {
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '10px 14px',
border: '1px solid var(--line)',
borderRadius: 8,
}

View File

@@ -0,0 +1,137 @@
import { useCallback, useEffect, useState } from 'react'
import { api } from '../../api/client.js'
import TrustLimitModal from './TrustLimitModal.jsx'
// Self-service list of the devices allowed to skip the TOTP step at login (MFA
// "Trust this device"). Uses the role-agnostic /auth/me/trusted-devices surface, so
// the same panel serves players and staff. Shown only when 2FA is enabled — trust
// is meaningless without a second factor to skip.
function fmtDate(s) {
if (!s) return '—'
const d = new Date(s)
return Number.isNaN(d.getTime()) ? '—' : d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
}
export default function TrustedDevicesPanel() {
const [devices, setDevices] = useState(null)
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const [capModal, setCapModal] = useState(null) // { devices } when the cap is hit
const load = useCallback(async () => {
try {
setDevices(await api.myTrustedDevices())
} catch {
setError('Could not load your trusted devices.')
}
}, [])
useEffect(() => {
load()
}, [load])
async function trustThis() {
setBusy(true)
setMsg('')
setError('')
try {
await api.trustThisDevice()
setMsg('This device is now trusted.')
await load()
} catch (err) {
if (err.status === 409 && err.body?.error === 'trusted_device_limit') {
setCapModal({ devices: err.body.devices || [] })
} else {
setError('Could not trust this device.')
}
} finally {
setBusy(false)
}
}
async function revoke(id) {
setBusy(true)
setMsg('')
setError('')
try {
await api.revokeTrustedDevice(id)
await load()
} catch {
setError('Could not revoke that device.')
} finally {
setBusy(false)
}
}
async function revokeAll() {
if (!window.confirm('Untrust every device? Each will require the full two-factor step at the next login.')) return
setBusy(true)
setMsg('')
setError('')
try {
await api.revokeAllTrustedDevices()
setMsg('All devices untrusted.')
await load()
} catch {
setError('Could not untrust devices.')
} finally {
setBusy(false)
}
}
if (!devices) return null
return (
<div style={{ marginTop: 40, borderTop: '1px solid var(--line-soft)', paddingTop: 28 }}>
<h2 className="display" style={{ marginTop: 0, fontSize: '1.2rem', color: 'var(--head)' }}>
Trusted devices
</h2>
<p className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
Devices youve trusted skip the authenticator step at login (your password is still required).
Revoke any you dont recognize.
</p>
{devices.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, margin: '14px 0' }}>
{devices.map((d) => (
<div key={d.id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem' }}>
{d.deviceName || (d.platform === 'mobile' ? 'Mobile app' : 'Browser')}
</div>
<div className="sans dim" style={{ fontSize: '0.76rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{d.userAgent || '—'} · last used {fmtDate(d.lastUsedAt)} · expires {fmtDate(d.expiresAt)}
</div>
</div>
<button onClick={() => revoke(d.id)} disabled={busy} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
Revoke
</button>
</div>
))}
</div>
) : (
<p className="sans dim" style={{ fontSize: '0.86rem', margin: '14px 0' }}>No trusted devices yet.</p>
)}
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={trustThis} disabled={busy} className="btn btn-sq">Trust this device</button>
{devices.length > 0 && (
<button onClick={revokeAll} disabled={busy} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
Untrust all
</button>
)}
</div>
{msg && <p className="sans" style={{ marginTop: 14, color: '#7fd0a4', fontSize: '0.86rem' }}>{msg}</p>}
{error && <p className="sans" style={{ marginTop: 14, color: '#d98b84', fontSize: '0.86rem' }}>{error}</p>}
{capModal && (
<TrustLimitModal
devices={capModal.devices}
onTrusted={() => { setCapModal(null); setMsg('This device is now trusted.'); load() }}
onCancel={() => setCapModal(null)}
/>
)}
</div>
)
}

View File

@@ -1,4 +1,4 @@
import { createContext, useContext, useEffect, useState, useCallback } from 'react' import { createContext, useContext, useEffect, useState, useCallback, useMemo } from 'react'
import { api } from '../api/client.js' import { api } from '../api/client.js'
const AuthContext = createContext(null) const AuthContext = createContext(null)
@@ -38,17 +38,22 @@ export function AuthProvider({ children }) {
return data return data
}, []) }, [])
// Step 2 for TOTP users: exchange the challenge + code for a real session. // Step 2 for TOTP users: exchange the challenge + a second factor (TOTP code or a
const loginTotp = useCallback(async (challenge, code) => { // recovery code) for a real session. `extra` carries recoveryCode + the
const data = await api.loginTotp(challenge, code) // trustDevice/deviceName opt-in. Returns the full payload ({ user,
// trustLimitReached?, devices? }) so the caller can handle the device-cap prompt.
const loginTotp = useCallback(async (challenge, code, extra) => {
const data = await api.loginTotp(challenge, code, extra)
setUser(data.user) setUser(data.user)
return data.user return data
}, []) }, [])
// 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
}, []) }, [])
@@ -61,8 +66,15 @@ export function AuthProvider({ children }) {
} }
}, []) }, [])
// Memoized so consumers don't re-render on every provider render (the callbacks
// are already stable via useCallback).
const value = useMemo(
() => ({ user, loading, login, register, loginTotp, ssoLoginTotp, logout, refresh }),
[user, loading, login, register, loginTotp, ssoLoginTotp, logout, refresh],
)
return ( return (
<AuthContext.Provider value={{ user, loading, login, register, loginTotp, ssoLoginTotp, logout, refresh }}> <AuthContext.Provider value={value}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
) )

View File

@@ -1,5 +1,6 @@
import { createContext, useContext, useEffect, useState, useCallback } 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 {
@@ -23,25 +29,57 @@ export function SiteProvider({ children }) {
refresh() refresh()
}, [refresh]) }, [refresh])
const brand = settings.brand || {} 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])
const value = { // Memoized so consumers don't re-render on every provider render (brand is a
settings, // fresh object each render, which would otherwise churn the context value).
loading, const value = useMemo(
refresh, () => ({
brand, settings,
mode: settings.site_mode || 'live', loading,
siteTitle: brand.name || settings.site_title || 'Runic Gateway', refresh,
siteShortName: brand.shortName || brand.name || settings.site_title || 'Runic Gateway', brand,
contactEmail: brand.contactEmail || settings.contact_email || '', mode: settings.site_mode || 'live',
heroImage: brand.hero || '/assets/img/runic-emblem.png', siteTitle: brand.name || settings.site_title || 'Runic Gateway',
} siteShortName: brand.shortName || brand.name || settings.site_title || 'Runic Gateway',
contactEmail: brand.contactEmail || settings.contact_email || '',
heroImage: brand.hero || '/assets/img/runic-emblem.png',
}),
[settings, loading, refresh, brand],
)
return <SiteContext.Provider value={value}>{children}</SiteContext.Provider> return <SiteContext.Provider value={value}>{children}</SiteContext.Provider>
} }

View File

@@ -4,6 +4,16 @@
// membership) and the widget follows. Anything not matched lands in "Wilderness" // membership) and the widget follows. Anything not matched lands in "Wilderness"
// so the bucket counts always reconcile to the true total. // 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 // Ordered list of buckets. `label` shows in the widget; `match(region)` decides
// membership. First matching bucket wins; the last bucket is the catch-all. // membership. First matching bucket wins; the last bucket is the catch-all.
export const BUCKETS = [ export const BUCKETS = [
@@ -17,10 +27,10 @@ export const BUCKETS = [
id: 'towns', id: 'towns',
label: 'Towns', label: 'Towns',
// The other named cities/towns. // The other named cities/towns.
match: (r) => match: (r) => {
/^(moonglow|minoc|trinsic|jhelom|yew|skara ?brae|magincia|new ?magincia|vesper|nujelm|cove|ocllo|serpent'?s? hold|wind|delucia|papua)/i.test( const norm = normalizeRegion(r)
r, return TOWN_PREFIXES.some((t) => norm.startsWith(t))
), },
}, },
{ {
id: 'dungeons', id: 'dungeons',

View File

@@ -0,0 +1,502 @@
// 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
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 shard feature 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 shard
* visibility must not render as a menu that opens onto nothing. The predicate
* stays the caller's, so this module still knows nothing about shard features.
*
* 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 (shard-feature gated), 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

@@ -10,73 +10,95 @@ function nameOf(who) {
const n = (v) => Number(v || 0).toLocaleString() 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 // A one-line human description of an event. Accepts either a stored event
// (with .payload) or a raw live frame (fields at top level). // (with .payload) or a raw live frame (fields at top level).
export function describe(ev) { export function describe(ev) {
const p = ev.payload || ev const fmt = DESCRIBERS[ev.kind]
switch (ev.kind) { return fmt ? fmt(ev.payload || ev) : ev.kind
case 'vendor.sale':
return `${p.itemType || 'An item'}${p.amount > 1 ? ` ×${p.amount}` : ''} sold for ${n(p.price)}gp`
case 'player.death':
return `${nameOf(p.who)} was slain${p.killer ? ` by ${nameOf(p.killer)}` : ''}`
case 'player.murdered':
return `${nameOf(p.victim)} was murdered${p.murderer ? ` by ${nameOf(p.murderer)}` : ''}`
case 'mob.killed':
return `${nameOf(p.killer)} killed ${nameOf(p.killed)}`
case 'skill.gain':
return `${nameOf(p.who)} gained ${p.skill}${p.base != null ? ` (${p.base})` : ''}`
case 'fame.change':
return `${nameOf(p.who)}s fame changed to ${n(p.new)}`
case 'karma.change':
return `${nameOf(p.who)}s karma changed to ${n(p.new)}`
case 'quest.complete':
return `${nameOf(p.who)} completed “${p.quest}`
case 'house.decay':
return `${p.name || 'A house'} is now ${p.to || p.stage}${p.region ? `${p.region}` : ''}`
case 'mob.login':
return `${nameOf(p.who)} entered the world`
case 'mob.logout':
return `${nameOf(p.who)} left the world`
case 'economy.supply':
return `Gold supply: ${n(p.gold)} across ${n(p.accounts)} accounts`
case 'server.hello':
return `Shard online — ${n(p.accounts)} accounts, ${n(p.mobiles)} mobiles`
case 'server.shutdown':
return 'Shard shut down'
case 'server.crashed':
return `Shard crashed${p.error ? `: ${p.error}` : ''}`
case 'champ.update': {
const where = p.name || p.type || 'A champion spawn'
if (p.status === 'active' && p.bossUp) return `${where}: boss is up${p.boss ? ` (${p.boss})` : ''}`
if (p.status === 'active') return `${where} is active${p.level != null ? ` — level ${p.level}` : ''}`
if (p.status === 'cooldown') return `${where} is on cooldown`
return `${where} is ${p.status || 'idle'}`
}
case 'champ.remove':
return `A champion spawn ended`
// Support (help-page) queue + in-game moderation (admin channel only)
case 'page.new':
return `New ${p.type || 'help'} page from ${nameOf(p.sender)}`
case 'page.updated':
return `Help page from ${nameOf(p.sender)} updated${p.handled ? ' (claimed)' : ''}`
case 'page.closed':
return `Help page ${p.pageId || ''} closed`
case 'admin.audit':
return `${p.actor || 'Staff'} ${p.action || 'acted'}${p.target ? ` on ${p.target}` : ''}${p.origin ? ` [${p.origin}]` : ''}`
// Staff / sensitive (admin channel only)
case 'audit.set':
return `${nameOf(p.staff) || 'Staff'} set ${p.prop} on ${p.target || p.targetSerial} (${p.old}${p.new})`
case 'audit.command':
return `${nameOf(p.staff) || 'Staff'} ran ${p.command}${p.args ? ` ${p.args}` : ''}`
case 'cheat.fastwalk':
return `Fast-walk flagged: ${nameOf(p.who)}${p.ip ? ` (${p.ip})` : ''}`
case 'account.login.attempt':
return `Login attempt: ${p.acct}${p.ip ? ` from ${p.ip}` : ''}`
case 'gold.change':
return `${p.acct}: gold ${p.delta >= 0 ? '+' : ''}${n(p.delta)}${n(p.new)}`
default:
return ev.kind
}
} }
// Category grouping for the filter tabs. // Category grouping for the filter tabs.

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

@@ -0,0 +1,58 @@
import { useEffect, useState } from 'react'
import { api } from '../api/client.js'
// Which shard surfaces the current viewer may reach, from
// GET /public/shard/features. Admins configure this per feature (Admin → Shard
// Visibility), so the nav can't be a static list any more.
//
// This is PRESENTATION only. The gate is server-side: a disabled feature 404s
// and an out-of-rung one 403s whether or not the link is rendered. So while the
// answer is still in flight we return `null` and callers show their default set
// — better a link that briefly 403s than a nav that flickers in on every load.
//
// Cached module-level: the answer is per-viewer but stable for a session, and
// every consumer would otherwise refetch it on mount.
let cached = null
let inFlight = null
export function resetShardFeatures() {
cached = null
inFlight = null
}
export function useShardFeatures() {
const [features, setFeatures] = useState(cached)
useEffect(() => {
if (cached) return undefined
let alive = true
inFlight =
inFlight ||
api.shard
.features()
.then((data) => {
cached = { level: data.level, set: new Set(data.features || []) }
return cached
})
.catch(() => {
// A failed lookup must not blank the nav — fall back to "show
// everything" and let the server do the gating.
cached = null
inFlight = null
return null
})
inFlight.then((result) => {
if (alive) setFeatures(result)
})
return () => {
alive = false
}
}, [])
return features
}
// Convenience: true when `name` is visible, or when we don't know yet.
export function canSee(features, name) {
return !features || features.set.has(name)
}

View File

@@ -1,8 +1,11 @@
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'
// 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).
@@ -38,13 +41,19 @@ const IconBot = () => <Icon><rect x="4" y="8" width="16" height="11" rx="2" /><p
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 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'] },
@@ -74,10 +83,14 @@ 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/shard', label: 'Shard (uo-link)', icon: IconShard, roles: ['admin'] },
{ to: '/admin/shard-visibility', label: 'Shard Visibility', icon: IconShard, roles: ['admin'] },
{ to: '/admin/shard-atlas', label: 'Spawn Atlas', 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'] },
], ],
}, },
@@ -91,6 +104,35 @@ const NAV = [
const COLLAPSE_KEY = 'admin.nav.collapsed' const COLLAPSE_KEY = 'admin.nav.collapsed'
// Moderators only get the moderation section (Discord + in-game ops) + their
// own account security.
const MOD_PATHS = ['/admin/moderation', '/admin/moderation/appeals', '/admin/shard-ops', '/admin/houses', '/admin/account']
// 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. The single authority for that question: the layout
// applies it after the override merge (overrides are presentation, this is the
// boundary — §7), and Admin -> Navigation applies it to build its palette, so an
// admin is never offered a row they cannot themselves see (§8.1).
export function navItemVisibleTo(item, role) {
if (item.roles && !item.roles.includes(role)) return false
if (role === 'moderator') return MOD_PATHS.includes(item.to)
return true
}
const TITLES = { const TITLES = {
'/admin': 'Dashboard', '/admin': 'Dashboard',
'/admin/posts': 'Posts', '/admin/posts': 'Posts',
@@ -102,10 +144,14 @@ const TITLES = {
'/admin/shard-ops': 'In-Game Ops', '/admin/shard-ops': 'In-Game Ops',
'/admin/houses': 'House Registry', '/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/shard': 'Shard (uo-link)',
'/admin/shard-visibility': 'Shard Visibility',
'/admin/shard-atlas': 'Spawn Atlas',
'/admin/characters': 'My Characters', '/admin/characters': 'My Characters',
'/admin/auth-providers': 'Authentication', '/admin/auth-providers': 'Authentication',
'/admin/users': 'Users', '/admin/users': 'Users',
@@ -113,6 +159,14 @@ const TITLES = {
'/admin/account': 'Account Security', '/admin/account': 'Account Security',
} }
// Fallback page title for dynamic sub-routes not in the exact-match TITLES map.
function sectionTitle(pathname) {
if (pathname.startsWith('/admin/moderation')) return 'Moderation'
if (pathname.startsWith('/admin/characters')) return 'My Characters'
if (pathname.startsWith('/admin/users/')) return 'User'
return 'Admin'
}
const navBtnBase = { const navBtnBase = {
textAlign: 'left', textAlign: 'left',
borderRadius: 8, borderRadius: 8,
@@ -129,35 +183,29 @@ 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 = const title = TITLES[location.pathname] || sectionTitle(location.pathname)
TITLES[location.pathname] ||
(location.pathname.startsWith('/admin/moderation')
? 'Moderation'
: location.pathname.startsWith('/admin/characters')
? 'My Characters'
: location.pathname.startsWith('/admin/users/')
? 'User'
: 'Admin')
// 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(NAV, keepEditorReachable(navOverrides.nav_admin))
.map((g) => ({ ...g, items: g.items.filter(visible) })) .map((g) => ({ ...g, items: g.items.filter((item) => navItemVisibleTo(item, user?.role)) }))
.filter((g) => g.items.length > 0) // Drop any now-empty group so an empty category header never renders.
.filter((g) => g.items.length > 0),
[navOverrides.nav_admin, user?.role],
)
// 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.
@@ -224,6 +272,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' }}>
@@ -236,7 +285,7 @@ export default function AdminLayout() {
</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' }}>
{navGroups.map((group, gi) => { {navGroups.map((group) => {
const links = group.items.map((n) => ( const links = group.items.map((n) => (
<NavLink <NavLink
key={n.to} key={n.to}
@@ -258,7 +307,7 @@ export default function AdminLayout() {
// Untitled groups (Dashboard, Account) render their links directly. // Untitled groups (Dashboard, Account) render their links directly.
if (!group.title) { if (!group.title) {
return ( return (
<div key={`g${gi}`} style={{ display: 'flex', flexDirection: 'column', gap: 4 }}> <div key={group.items[0]?.to || 'group'} style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{links} {links}
</div> </div>
) )

View File

@@ -1,7 +1,9 @@
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 { useAuth } from '../../contexts/AuthContext.jsx' import { useAuth } from '../../contexts/AuthContext.jsx'
import { useSite } from '../../contexts/SiteContext.jsx' import { useSite } from '../../contexts/SiteContext.jsx'
import { api } from '../../api/client.js' import { api } from '../../api/client.js'
@@ -52,6 +54,9 @@ export default function AdminLogin() {
const [challenge, setChallenge] = useState('') const [challenge, setChallenge] = useState('')
const [code, setCode] = useState('') const [code, setCode] = useState('')
const [ssoTotp, setSsoTotp] = useState(false) const [ssoTotp, setSsoTotp] = useState(false)
const [trustDevice, setTrustDevice] = useState(false)
const [useRecovery, setUseRecovery] = useState(false)
const [trustLimit, setTrustLimit] = useState(null) // { devices, dest } when the cap is hit
// SSO providers to offer (empty if none configured) + any error the callback // SSO providers to offer (empty if none configured) + any error the callback
// bounced us back with (?sso_error=...). // bounced us back with (?sso_error=...).
@@ -119,19 +124,34 @@ 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 u = await loginTotp(challenge, code) const entered = code.trim()
navigate(destFor(u), { replace: true }) const data = await loginTotp(challenge, useRecovery ? '' : entered, {
recoveryCode: useRecovery ? entered : undefined,
trustDevice,
})
const to = destFor(data.user)
if (data.trustLimitReached) {
setTrustLimit({ devices: data.devices || [], dest: to })
setBusy(false)
return
}
navigate(to, { replace: true })
} }
} catch (err) { } catch (err) {
const expired = err.status === 401 && /expired/i.test(err.message) const expired = err.status === 401 && /expired/i.test(err.message)
setError( const badRecovery = useRecovery ? 'That recovery code is not valid.' : 'Invalid verification code.'
expired setError(expired ? 'Your verification session expired. Please sign in again.' : badRecovery)
? 'Your verification session expired. Please sign in again.'
: 'Invalid verification code.',
)
setBusy(false) setBusy(false)
if (expired) { if (expired) {
setStage('creds') setStage('creds')
@@ -140,6 +160,10 @@ export default function AdminLogin() {
} }
} }
let submitLabel = 'Sign in'
if (busy) submitLabel = 'Signing in…'
else if (stage === 'totp') submitLabel = 'Verify'
return ( return (
<main <main
style={{ style={{
@@ -158,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)' }}>
@@ -219,22 +247,42 @@ export default function AdminLogin() {
</div> </div>
</> </>
) : ( ) : (
<label style={{ display: 'block', marginBottom: 22 }}> <>
<span className="field-label">Authentication code</span> <label style={{ display: 'block', marginBottom: 14 }}>
<input <span className="field-label">{useRecovery ? 'Recovery code' : 'Authentication code'}</span>
type="text" <input
inputMode="numeric" type="text"
autoComplete="one-time-code" inputMode={useRecovery ? 'text' : 'numeric'}
autoFocus autoComplete="one-time-code"
placeholder="6-digit code" autoFocus
value={code} placeholder={useRecovery ? 'xxxxx-xxxxx' : '6-digit code'}
onChange={(e) => setCode(e.target.value)} value={code}
className="input" onChange={(e) => setCode(e.target.value)}
/> className="input"
<span className="sans" style={{ display: 'block', marginTop: 8, color: 'var(--dim)', fontSize: '0.76rem' }}> />
Enter the code from your authenticator app. <span className="sans" style={{ display: 'block', marginTop: 8, color: 'var(--dim)', fontSize: '0.76rem' }}>
</span> {useRecovery ? 'Enter one of your saved single-use recovery codes.' : 'Enter the code from your authenticator app.'}
</label> </span>
</label>
{/* 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' }}>
<input type="checkbox" checked={trustDevice} onChange={(e) => setTrustDevice(e.target.checked)} />
Trust this device for 30 days (skip the code next time)
</label>
{/* Recovery codes remain password-login only: the SSO second step
verifies an authenticator code against the staged challenge. */}
{!ssoTotp && (
<button
type="button"
onClick={() => { setUseRecovery((v) => !v); setCode('') }}
className="sans"
style={{ display: 'block', marginBottom: 22, background: 'none', border: 'none', padding: 0, color: 'var(--accent)', cursor: 'pointer', fontSize: '0.8rem' }}
>
{useRecovery ? 'Use an authenticator code instead' : 'Use a recovery code instead'}
</button>
)}
</>
)} )}
{(error || (stage === 'creds' && ssoError)) && ( {(error || (stage === 'creds' && ssoError)) && (
@@ -249,7 +297,7 @@ export default function AdminLogin() {
className="btn btn-primary" className="btn btn-primary"
style={{ display: 'block', width: '100%', borderRadius: 8, padding: 12, textAlign: 'center' }} style={{ display: 'block', width: '100%', borderRadius: 8, padding: 12, textAlign: 'center' }}
> >
{busy ? 'Signing in…' : stage === 'totp' ? 'Verify' : 'Sign in'} {submitLabel}
</button> </button>
{/* SSO providers — only on the credentials step, only if any are enabled. */} {/* SSO providers — only on the credentials step, only if any are enabled. */}
@@ -300,6 +348,14 @@ export default function AdminLogin() {
</Link> </Link>
</p> </p>
</div> </div>
{trustLimit && (
<TrustLimitModal
devices={trustLimit.devices}
onTrusted={() => navigate(trustLimit.dest, { replace: true })}
onCancel={() => navigate(trustLimit.dest, { replace: true })}
/>
)}
</main> </main>
) )
} }

View File

@@ -1,6 +1,9 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx' import { Loading, ErrorState } from '../../../components/PageState.jsx'
import ProviderIcon from '../../../components/ProviderIcon.jsx' import ProviderIcon from '../../../components/ProviderIcon.jsx'
import RecoveryCodesDisplay from '../../../components/security/RecoveryCodesDisplay.jsx'
import TrustedDevicesPanel from '../../../components/security/TrustedDevicesPanel.jsx'
import RecoveryCodesPanel from '../../../components/security/RecoveryCodesPanel.jsx'
import { api } from '../../../api/client.js' import { api } from '../../../api/client.js'
// Link/unlink external SSO identities to this account. Linking redirects through // Link/unlink external SSO identities to this account. Linking redirects through
@@ -123,10 +126,11 @@ export default function AccountAdmin() {
const [error, setError] = useState('') const [error, setError] = useState('')
// Enrollment state. // Enrollment state.
const [setup, setSetup] = useState(null) // { qr, otpauthUrl } const [setup, setSetup] = useState(null) // fields qr and otpauthUrl once enrolling
const [code, setCode] = useState('') const [code, setCode] = useState('')
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('') const [msg, setMsg] = useState('')
const [newCodes, setNewCodes] = useState(null) // one-time recovery codes shown after enabling
async function load() { async function load() {
try { try {
@@ -164,9 +168,10 @@ export default function AccountAdmin() {
setMsg('') setMsg('')
setError('') setError('')
try { try {
await api.admin.totpEnable(code.trim()) const res = await api.admin.totpEnable(code.trim())
setSetup(null) setSetup(null)
setCode('') setCode('')
setNewCodes(res?.recoveryCodes || null)
setMsg('Two-factor authentication is now enabled.') setMsg('Two-factor authentication is now enabled.')
await load() await load()
} catch (err) { } catch (err) {
@@ -302,6 +307,21 @@ export default function AccountAdmin() {
{msg && <p className="sans" style={{ marginTop: 16, color: '#7fd0a4', fontSize: '0.86rem' }}>{msg}</p>} {msg && <p className="sans" style={{ marginTop: 16, color: '#7fd0a4', fontSize: '0.86rem' }}>{msg}</p>}
{error && <p className="sans" style={{ marginTop: 16, color: '#d98b84', fontSize: '0.86rem' }}>{error}</p>} {error && <p className="sans" style={{ marginTop: 16, color: '#d98b84', fontSize: '0.86rem' }}>{error}</p>}
{/* One-time recovery codes shown right after enabling 2FA. */}
{newCodes && (
<div style={{ marginTop: 20 }}>
<RecoveryCodesDisplay codes={newCodes} onDone={() => setNewCodes(null)} />
</div>
)}
{/* Trusted devices + recovery-code management, only relevant with 2FA on. */}
{enabled && (
<>
<TrustedDevicesPanel />
<RecoveryCodesPanel hasPassword={account?.has_password !== false} />
</>
)}
<LinkedAccounts /> <LinkedAccounts />
</section> </section>
) )

View File

@@ -48,7 +48,7 @@ export default function Appeals() {
const reload = useCallback(() => setTick((t) => t + 1), []) const reload = useCallback(() => setTick((t) => t + 1), [])
const [busyId, setBusyId] = useState('') const [busyId, setBusyId] = useState('')
const [resolving, setResolving] = useState(null) // the appeal being resolved const [resolving, setResolving] = useState(null) // the appeal being resolved
const [notice, setNotice] = useState(null) // { text, tone } const [notice, setNotice] = useState(null) // fields text and tone
const activeTab = STATUS_TABS.find((t) => t.key === tab) || STATUS_TABS[0] const activeTab = STATUS_TABS.find((t) => t.key === tab) || STATUS_TABS[0]
const { loading, error, data } = useAsync( const { loading, error, data } = useAsync(
@@ -210,6 +210,8 @@ function ResolveModal({ appeal, onClose, onResolved }) {
} }
} }
const verb = status === 'approved' ? 'approved' : 'denied'
return ( return (
<Modal <Modal
title={`Resolve appeal — ${appeal.action_target_tag || appeal.discord_user_id}`} title={`Resolve appeal — ${appeal.action_target_tag || appeal.discord_user_id}`}
@@ -221,7 +223,7 @@ function ResolveModal({ appeal, onClose, onResolved }) {
Cancel Cancel
</button> </button>
<button onClick={submit} disabled={busy} className="btn btn-primary btn-sq"> <button onClick={submit} disabled={busy} className="btn btn-primary btn-sq">
{busy ? 'Saving…' : `Mark ${status === 'approved' ? 'approved' : 'denied'}`} {busy ? 'Saving…' : `Mark ${verb}`}
</button> </button>
</> </>
} }

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

@@ -44,6 +44,14 @@ function CallbackHint({ id }) {
) )
} }
// Live = enabled and healthy; Incomplete = enabled but missing/invalid config;
// Disabled otherwise.
function ProviderStatus({ provider: p }) {
if (p.enabled && p.health.valid) return <span className="sans" style={{ color: '#7fd0a4' }}>Live</span>
if (p.enabled) return <span className="sans" style={{ color: '#e0b070' }}>Incomplete</span>
return <span className="sans dim">Disabled</span>
}
function Toggle({ checked, onChange, label }) { function Toggle({ checked, onChange, label }) {
return ( return (
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: '0.9rem', color: 'var(--ink)' }}> <label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: '0.9rem', color: 'var(--ink)' }}>
@@ -292,13 +300,7 @@ function CustomProviders({ items, onChanged }) {
<td className="adm-td" style={{ color: 'var(--head)' }}>{p.name}</td> <td className="adm-td" style={{ color: 'var(--head)' }}>{p.name}</td>
<td className="adm-td dim">{p.kind}</td> <td className="adm-td dim">{p.kind}</td>
<td className="adm-td"> <td className="adm-td">
{p.enabled && p.health.valid ? ( <ProviderStatus provider={p} />
<span className="sans" style={{ color: '#7fd0a4' }}>Live</span>
) : p.enabled ? (
<span className="sans" style={{ color: '#e0b070' }}>Incomplete</span>
) : (
<span className="sans dim">Disabled</span>
)}
</td> </td>
<td className="adm-td" style={{ textAlign: 'right' }}> <td className="adm-td" style={{ textAlign: 'right' }}>
<span className="link-accent" onClick={() => setEditing(p)}>Edit</span> <span className="link-accent" onClick={() => setEditing(p)}>Edit</span>

View File

@@ -115,8 +115,8 @@ export default function BotActivityAdmin() {
</td> </td>
</tr> </tr>
)} )}
{events.map((ev, i) => ( {events.map((ev) => (
<tr key={`${ev.ts}-${ev.ip}-${i}`}> <tr key={`${ev.ts}-${ev.ip}-${ev.type}`}>
<td className="adm-td dim">{dateTime(ev.ts)}</td> <td className="adm-td dim">{dateTime(ev.ts)}</td>
<td className="adm-td" style={{ ...mono, color: 'var(--text)' }}> <td className="adm-td" style={{ ...mono, color: 'var(--text)' }}>
{ev.ip} {ev.ip}

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)
} }
@@ -45,6 +61,9 @@ export default function Dashboard() {
const changed = dash.last_change || {} const changed = dash.last_change || {}
let modeLabel = isLive ? 'Switch to Maintenance' : 'Switch to Live'
if (busy) modeLabel = 'Saving…'
return ( return (
<section> <section>
<div <div
@@ -75,15 +94,22 @@ 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>
<button {isAdmin && (
onClick={toggle} <button
disabled={busy} onClick={toggle}
className="sans" disabled={busy}
style={{ border: '1px solid var(--accent)', borderRadius: 999, padding: '11px 24px', background: 'rgba(127,153,189,0.14)', color: '#d8e2ef', fontWeight: 600, fontSize: '0.9rem', cursor: 'pointer' }} className="sans"
> style={{ border: '1px solid var(--accent)', borderRadius: 999, padding: '11px 24px', background: 'rgba(127,153,189,0.14)', color: '#d8e2ef', fontWeight: 600, fontSize: '0.9rem', cursor: 'pointer' }}
{busy ? 'Saving…' : isLive ? 'Switch to Maintenance' : 'Switch to Live'} >
</button> {modeLabel}
</button>
)}
</div> </div>
<div className="grid-4" style={{ gap: 14, marginBottom: 28 }}> <div className="grid-4" style={{ gap: 14, marginBottom: 28 }}>

View File

@@ -61,7 +61,7 @@ export default function EmailDelivery() {
const [busy, setBusy] = useState('') const [busy, setBusy] = useState('')
const [msg, setMsg] = useState('') const [msg, setMsg] = useState('')
const [actionError, setActionError] = useState('') const [actionError, setActionError] = useState('')
const [banner, setBanner] = useState(null) // { kind: 'ok'|'err', text } const [banner, setBanner] = useState(null) // fields kind ('ok' or 'err') and text
const load = useCallback(async (seedForm = false) => { const load = useCallback(async (seedForm = false) => {
try { try {

View File

@@ -23,6 +23,34 @@ function tooLargeToUpload(size) {
) )
} }
// Label for an image-upload button: busy, replace-existing, or first upload.
function uploadLabel(up, hasSrc) {
if (up) return 'Uploading…'
return hasSrc ? 'Replace' : 'Upload'
}
// Shared image-upload behaviour for the element panels that point props.src at
// the uploaded URL (moon + image). Returns the busy flag and file <input> handler.
function useImageUpload(onProps) {
const [up, setUp] = useState(false)
async function onFile(e) {
const f = e.target.files?.[0]
e.target.value = ''
if (!f) return
if (tooLargeToUpload(f.size)) return
setUp(true)
try {
const { url } = await api.admin.upload(f)
onProps({ src: url })
} catch {
/* ignore */
} finally {
setUp(false)
}
}
return { up, onFile }
}
function newElement(type, z) { function newElement(type, z) {
const base = { id: genId(), type, x: 50, y: 50, z, anchor: 'center' } const base = { id: genId(), type, x: 50, y: 50, z, anchor: 'center' }
if (type === 'text_block') { if (type === 'text_block') {
@@ -53,6 +81,23 @@ function scaleFontSize(v, ratio) {
return v return v
} }
// The props patch for a resize drag, per element type: image width is a % of the
// canvas, moon size is px, and a text_block resizes its box and scales every
// line's font proportionally. `ctx` carries the drag origin + measured geometry.
function resizePatch(el, ctx) {
const { orig, dxPx, dxLogical, rectWidth, baseWidth, baseLines } = ctx
if (el.type === 'image') {
return { width: Math.round(clamp(orig + (dxPx / rectWidth) * 100, 5, 100)) } // %
}
if (el.type === 'moon') {
return { size: Math.round(clamp(orig + dxLogical, 24, 400)) } // px
}
const width = Math.round(clamp(orig + dxLogical, 120, 1180))
const ratio = baseWidth ? width / baseWidth : 1
const lines = baseLines.map((l) => ({ ...l, fontSize: scaleFontSize(l.fontSize, ratio) }))
return { width, lines }
}
export default function HeroEditor() { export default function HeroEditor() {
const [layout, setLayout] = useState(null) const [layout, setLayout] = useState(null)
const [live, setLive] = useState(null) const [live, setLive] = useState(null)
@@ -203,7 +248,9 @@ export default function HeroEditor() {
if (!dim) return if (!dim) return
const rect = canvasRef.current.getBoundingClientRect() const rect = canvasRef.current.getBoundingClientRect()
const sx = e.clientX const sx = e.clientX
const orig = el.props?.[dim] ?? (dim === 'width' && el.type === 'image' ? 40 : dim === 'width' ? 600 : 64) let defaultDim = 64
if (dim === 'width') defaultDim = el.type === 'image' ? 40 : 600
const orig = el.props?.[dim] ?? defaultDim
// Snapshot the starting width + lines for text blocks so font scaling is always // Snapshot the starting width + lines for text blocks so font scaling is always
// computed against the drag origin (no rounding drift as the pointer moves). // computed against the drag origin (no rounding drift as the pointer moves).
const baseWidth = el.type === 'text_block' ? orig : 0 const baseWidth = el.type === 'text_block' ? orig : 0
@@ -217,17 +264,7 @@ export default function HeroEditor() {
const move = (ev) => { const move = (ev) => {
const dxPx = ev.clientX - sx const dxPx = ev.clientX - sx
const dxLogical = dxPx / scale // client px → stage px const dxLogical = dxPx / scale // client px → stage px
if (el.type === 'image') { updateProps(el.id, resizePatch(el, { orig, dxPx, dxLogical, rectWidth: rect.width, baseWidth, baseLines }))
updateProps(el.id, { width: Math.round(clamp(orig + (dxPx / rect.width) * 100, 5, 100)) }) // %
} else if (el.type === 'moon') {
updateProps(el.id, { size: Math.round(clamp(orig + dxLogical, 24, 400)) }) // px
} else {
// text_block: resize the box and scale every line's font proportionally.
const width = Math.round(clamp(orig + dxLogical, 120, 1180))
const ratio = baseWidth ? width / baseWidth : 1
const lines = baseLines.map((l) => ({ ...l, fontSize: scaleFontSize(l.fontSize, ratio) }))
updateProps(el.id, { width, lines })
}
} }
const up = () => { const up = () => {
node.removeEventListener('pointermove', move) node.removeEventListener('pointermove', move)
@@ -534,24 +571,7 @@ const swatch = { width: '100%', height: 38, padding: 2, border: '1px solid var(-
function MoonPanel({ element, onProps }) { function MoonPanel({ element, onProps }) {
const p = element.props || {} const p = element.props || {}
const [up, setUp] = useState(false) const { up, onFile } = useImageUpload(onProps)
// Reuses the shared admin upload endpoint (same as the image/background panels);
// a successful upload just points props.src at the returned URL.
async function onFile(e) {
const f = e.target.files?.[0]
e.target.value = ''
if (!f) return
if (tooLargeToUpload(f.size)) return
setUp(true)
try {
const { url } = await api.admin.upload(f)
onProps({ src: url })
} catch {
/* ignore */
} finally {
setUp(false)
}
}
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div> <div>
@@ -562,7 +582,7 @@ function MoonPanel({ element, onProps }) {
<p className="sans dim" style={{ margin: '0 0 8px', fontSize: '0.8rem' }}>Using the default moon from the hero artwork.</p> <p className="sans dim" style={{ margin: '0 0 8px', fontSize: '0.8rem' }}>Using the default moon from the hero artwork.</p>
)} )}
<label className="btn btn-ghost btn-sq" style={{ display: 'inline-block', cursor: 'pointer' }}> <label className="btn btn-ghost btn-sq" style={{ display: 'inline-block', cursor: 'pointer' }}>
{up ? 'Uploading…' : p.src ? 'Replace' : 'Upload'} {uploadLabel(up, !!p.src)}
<input type="file" accept="image/*" onChange={onFile} hidden disabled={up} /> <input type="file" accept="image/*" onChange={onFile} hidden disabled={up} />
</label> </label>
{p.src && ( {p.src && (
@@ -613,29 +633,14 @@ function BadgePanel({ element, onProps }) {
function ImagePanel({ element, onProps }) { function ImagePanel({ element, onProps }) {
const p = element.props || {} const p = element.props || {}
const [up, setUp] = useState(false) const { up, onFile } = useImageUpload(onProps)
async function onFile(e) {
const f = e.target.files?.[0]
e.target.value = ''
if (!f) return
if (tooLargeToUpload(f.size)) return
setUp(true)
try {
const { url } = await api.admin.upload(f)
onProps({ src: url })
} catch {
/* ignore */
} finally {
setUp(false)
}
}
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div> <div>
<span className="field-label">Image</span> <span className="field-label">Image</span>
{p.src && <img src={p.src} alt="" style={{ width: '100%', maxHeight: 90, objectFit: 'contain', borderRadius: 6, border: '1px solid var(--line)', marginBottom: 8 }} />} {p.src && <img src={p.src} alt="" style={{ width: '100%', maxHeight: 90, objectFit: 'contain', borderRadius: 6, border: '1px solid var(--line)', marginBottom: 8 }} />}
<label className="btn btn-ghost btn-sq" style={{ display: 'inline-block', cursor: 'pointer' }}> <label className="btn btn-ghost btn-sq" style={{ display: 'inline-block', cursor: 'pointer' }}>
{up ? 'Uploading…' : p.src ? 'Replace' : 'Upload'} {uploadLabel(up, !!p.src)}
<input type="file" accept="image/*" onChange={onFile} hidden disabled={up} /> <input type="file" accept="image/*" onChange={onFile} hidden disabled={up} />
</label> </label>
</div> </div>

View File

@@ -43,7 +43,7 @@ function CreateInvite({ onCreated }) {
const [sendEmail, setSendEmail] = useState(true) const [sendEmail, setSendEmail] = useState(true)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [result, setResult] = useState(null) // { emailed, acceptUrl, emailError } const [result, setResult] = useState(null) // fields emailed, acceptUrl, emailError
async function submit(e) { async function submit(e) {
e.preventDefault() e.preventDefault()
@@ -62,6 +62,16 @@ function CreateInvite({ onCreated }) {
} }
} }
const submitLabel = sendEmail ? 'Create & email' : 'Create link'
let resultText
if (result?.emailed) {
resultText = 'Invitation emailed. You can also share this single-use link:'
} else {
const emailNote = result?.emailError ? ` (email not sent: ${result.emailError})` : ''
resultText = `Invite created${emailNote}. Share this single-use link:`
}
return ( return (
<div className="panel" style={{ padding: 22, marginBottom: 22 }}> <div className="panel" style={{ padding: 22, marginBottom: 22 }}>
<div className="field-label" style={{ marginBottom: 10 }}>Invite someone</div> <div className="field-label" style={{ marginBottom: 10 }}>Invite someone</div>
@@ -77,7 +87,7 @@ function CreateInvite({ onCreated }) {
</select> </select>
</label> </label>
<button type="submit" disabled={busy} className="btn btn-primary btn-sq"> <button type="submit" disabled={busy} className="btn btn-primary btn-sq">
{busy ? 'Creating…' : (sendEmail ? 'Create & email' : 'Create link')} {busy ? 'Creating…' : submitLabel}
</button> </button>
</form> </form>
@@ -90,9 +100,7 @@ function CreateInvite({ onCreated }) {
{result && ( {result && (
<div style={{ marginTop: 14 }}> <div style={{ marginTop: 14 }}>
<p className="sans" style={{ margin: '0 0 8px', fontSize: '0.84rem', color: result.emailed ? '#7fd0a4' : 'var(--muted)' }}> <p className="sans" style={{ margin: '0 0 8px', fontSize: '0.84rem', color: result.emailed ? '#7fd0a4' : 'var(--muted)' }}>
{result.emailed {resultText}
? 'Invitation emailed. You can also share this single-use link:'
: `Invite created${result.emailError ? ` (email not sent: ${result.emailError})` : ''}. Share this single-use link:`}
</p> </p>
<CopyLink url={result.acceptUrl} /> <CopyLink url={result.acceptUrl} />
</div> </div>

View File

@@ -47,6 +47,15 @@ export default function ModerationUser() {
const counts = summary.counts || {} const counts = summary.counts || {}
const tabActions = actions.filter((a) => a.action_type === tab) const tabActions = actions.filter((a) => a.action_type === tab)
let tabBody
if (tab === 'notes') {
tabBody = <NotesTab discordId={discordId} notes={notes} isAdmin={isAdmin} onAdded={reload} />
} else if (tab === 'appeals') {
tabBody = <AppealsTab rows={appeals} />
} else {
tabBody = <ActionTable rows={tabActions} showDuration={tab === 'mute'} />
}
return ( return (
<section> <section>
<Link to="/admin/moderation" className="link-accent" style={{ fontSize: '0.85rem' }}> <Link to="/admin/moderation" className="link-accent" style={{ fontSize: '0.85rem' }}>
@@ -89,13 +98,7 @@ export default function ModerationUser() {
</TabButton> </TabButton>
</div> </div>
{tab === 'notes' ? ( {tabBody}
<NotesTab discordId={discordId} notes={notes} isAdmin={isAdmin} onAdded={reload} />
) : tab === 'appeals' ? (
<AppealsTab rows={appeals} />
) : (
<ActionTable rows={tabActions} showDuration={tab === 'mute'} />
)}
</section> </section>
) )
} }

View File

@@ -0,0 +1,545 @@
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 { useShardFeatures, canSee } from '../../../lib/useShardFeatures.js'
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 this shard's feature gates. 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 shardFeatures = useShardFeatures()
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 palette: each base nav, filtered to what THIS admin can see (§8.1). The
// public nav's gates are the shard-feature ones; the admin nav's are roles.
// The player portal has no gates at all.
// 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.
const fullNavs = { nav_public: PUBLIC_NAV, nav_admin: ADMIN_NAV, nav_player: PLAYER_NAV }
const palettes = useMemo(
() => ({
nav_public: PUBLIC_NAV.filter((item) => !item.feature || canSee(shardFeatures, item.feature)),
nav_admin: ADMIN_NAV.map((g) => ({ ...g, items: g.items.filter((i) => navItemVisibleTo(i, user?.role)) })).filter(
(g) => g.items.length > 0,
),
nav_player: PLAYER_NAV,
}),
[shardFeatures, 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 this shard&rsquo;s
visibility settings 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 Shard
Visibility keeps whatever it was already set to.
</p>
</section>
)
}

View File

@@ -252,6 +252,9 @@ export default function PageBuilder() {
const published = form.status === 'published' const published = form.status === 'published'
let saveLabel = isEdit ? 'Save' : 'Create'
if (busy) saveLabel = 'Saving…'
return ( return (
<section> <section>
{/* Toolbar */} {/* Toolbar */}
@@ -268,7 +271,7 @@ export default function PageBuilder() {
</button> </button>
)} )}
<button className="btn btn-primary btn-sq" onClick={() => save()} disabled={busy}> <button className="btn btn-primary btn-sq" onClick={() => save()} disabled={busy}>
{busy ? 'Saving…' : isEdit ? 'Save' : 'Create'} {saveLabel}
</button> </button>
</div> </div>
@@ -277,7 +280,7 @@ export default function PageBuilder() {
{error} {error}
{details.length > 0 && ( {details.length > 0 && (
<ul style={{ margin: '6px 0 0', paddingLeft: 18 }}> <ul style={{ margin: '6px 0 0', paddingLeft: 18 }}>
{details.map((d, i) => <li key={i}>{d}</li>)} {details.map((d) => <li key={d}>{d}</li>)}
</ul> </ul>
)} )}
</div> </div>
@@ -386,7 +389,7 @@ export default function PageBuilder() {
<span className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem' }}>Show in navigation</span> <span className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem' }}>Show in navigation</span>
</label> </label>
<SelectField label="Nav group" value={form.settings.navGroup} onChange={setSetting('navGroup')} options={NAV_GROUPS} /> <SelectField label="Nav group" value={form.settings.navGroup} onChange={setSetting('navGroup')} options={NAV_GROUPS} />
<TextField label="Nav order" value={form.settings.navOrder ?? ''} onChange={(v) => setSetting('navOrder')(v === '' ? null : v.replace(/[^0-9]/g, ''))} hint="Lower numbers appear first." /> <TextField label="Nav order" value={form.settings.navOrder ?? ''} onChange={(v) => setSetting('navOrder')(v === '' ? null : v.replace(/\D/g, ''))} hint="Lower numbers appear first." />
</div> </div>
</div> </div>

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

@@ -109,14 +109,15 @@ export default function SettingsAdmin() {
// A rich field can't live inside a <label> (nested toolbar buttons + // A rich field can't live inside a <label> (nested toolbar buttons +
// contenteditable), so it uses a plain <div> wrapper instead. // contenteditable), so it uses a plain <div> wrapper instead.
const Wrap = f.rich ? 'div' : 'label' const Wrap = f.rich ? 'div' : 'label'
return ( let field
<Wrap key={f.key} style={{ display: 'block' }}> if (f.rich) {
<span className="field-label">{f.label}</span> field = (
{f.rich ? (
<Suspense fallback={<span className="spin" />}> <Suspense fallback={<span className="spin" />}>
<RichTextEditor value={values[f.key]} onChange={setRaw(f.key)} variant="post" /> <RichTextEditor value={values[f.key]} onChange={setRaw(f.key)} variant="post" />
</Suspense> </Suspense>
) : f.options ? ( )
} else if (f.options) {
field = (
<select value={values[f.key]} onChange={set(f.key)} className="select"> <select value={values[f.key]} onChange={set(f.key)} className="select">
{f.options.map((o) => ( {f.options.map((o) => (
<option key={o.value} value={o.value}> <option key={o.value} value={o.value}>
@@ -124,11 +125,16 @@ export default function SettingsAdmin() {
</option> </option>
))} ))}
</select> </select>
) : f.long ? ( )
<textarea value={values[f.key]} onChange={set(f.key)} className="textarea" style={{ minHeight: 90 }} /> } else if (f.long) {
) : ( field = <textarea value={values[f.key]} onChange={set(f.key)} className="textarea" style={{ minHeight: 90 }} />
<input type="text" value={values[f.key]} onChange={set(f.key)} className="input" /> } else {
)} field = <input type="text" value={values[f.key]} onChange={set(f.key)} className="input" />
}
return (
<Wrap key={f.key} style={{ display: 'block' }}>
<span className="field-label">{f.label}</span>
{field}
{f.help && ( {f.help && (
<span className="sans dim" style={{ display: 'block', marginTop: 6, fontSize: '0.76rem' }}> <span className="sans dim" style={{ display: 'block', marginTop: 6, fontSize: '0.76rem' }}>
{f.help} {f.help}

View File

@@ -155,7 +155,7 @@ export default function ShardAdmin() {
const [baseUrl, setBaseUrl] = useState('') const [baseUrl, setBaseUrl] = useState('')
const [wsUrl, setWsUrl] = useState('') const [wsUrl, setWsUrl] = useState('')
const [token, setToken] = useState('') const [token, setToken] = useState('')
const [protocol, setProtocol] = useState(1) const [protocol, setProtocol] = useState(3)
const [enabled, setEnabled] = useState(false) const [enabled, setEnabled] = useState(false)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('') const [msg, setMsg] = useState('')
@@ -172,7 +172,7 @@ export default function ShardAdmin() {
if (!initializedRef.current) { if (!initializedRef.current) {
setBaseUrl(c.baseUrl || '') setBaseUrl(c.baseUrl || '')
setWsUrl(c.wsUrl || '') setWsUrl(c.wsUrl || '')
setProtocol(c.protocol || 1) setProtocol(c.protocol || 3)
setEnabled(c.enabled) setEnabled(c.enabled)
initializedRef.current = true initializedRef.current = true
} }

View File

@@ -91,9 +91,26 @@ function AccountActions() {
} }
const kick = () => const kick = () =>
run('kick', () => api.admin.shardOps.kick({ account: acct }), (r) => `Kicked ${acct}${r?.sessions != null ? ` (${r.sessions} session${r.sessions === 1 ? '' : 's'})` : ''}.`) 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 = () => const ban = () =>
run('ban', () => api.admin.shardOps.ban({ account: acct, durationSec: durationSec === '' ? undefined : Number(durationSec), reason: reason.trim() || undefined }), () => `Banned ${acct}${durationSec ? ` for ${durationSec}s` : ' indefinitely'}.`) 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}.`) const unban = () => run('unban', () => api.admin.shardOps.unban(acct), () => `Unbanned ${acct}.`)
return ( return (
@@ -200,6 +217,19 @@ function SupportQueue() {
return () => clearInterval(pollRef.current) return () => clearInterval(pollRef.current)
}, [load]) }, [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 ( return (
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22, display: 'flex', flexDirection: 'column', gap: 12 }}> <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> <h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Support queue</h3>
@@ -207,15 +237,7 @@ function SupportQueue() {
Open help pages from players. A reply reaches them in game (or on their next login). Open help pages from players. A reply reaches them in game (or on their next login).
</p> </p>
{err && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{err}</span>} {err && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{err}</span>}
{pages == null ? ( {queueBody}
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>Loading</p>
) : pages.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>The queue is empty.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{pages.map((p) => <PageRow key={p.pageId} page={p} onDone={load} />)}
</div>
)}
</section> </section>
) )
} }

View File

@@ -0,0 +1,325 @@
import { useCallback, useEffect, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
// ── Admin · Shard visibility ────────────────────────────────────────────────
//
// Who may see which shard surface, and which sensitive fields within it.
// Admin-only, because this decides what ANONYMOUS visitors get.
//
// Two things the UI must communicate honestly, because they are not negotiable
// server-side (see docs/link/v3.md §3.4):
// • acct / webId are admin-only always and are not listed as editable fields.
// • an event kind the server doesn't know about never reaches anyone below
// admin, whatever is set here.
//
// Defaults reproduce the behavior the site had before this panel existed, so a
// fresh install shows "everything as it was" rather than an empty form.
const RUNG_LABEL = {
anonymous: 'Everyone',
logged_in: 'Signed in',
player: 'Linked players',
staff: 'Staff',
admin: 'Admins only',
}
const RUNG_HINT = {
anonymous: 'Visible to anyone, signed in or not.',
logged_in: 'Any signed-in account, linked or not.',
player: 'Accounts with a linked game account. Staff always qualify.',
staff: 'Admins and moderators.',
admin: 'Admins only.',
}
const FEATURE_LABEL = {
status: 'Shard status',
activity: 'Activity feed',
champs: 'Champion spawns',
guilds: 'Guilds',
governors: 'Town governors',
houses: 'Houses / IDOC',
presence: 'Players online',
ruleset: 'Shard rules',
atlas: 'Spawn atlas',
leaderboards: 'Leaderboards',
market: 'Marketplace',
}
const FEATURE_HINT = {
status: 'Connection state, online count, gold-supply series.',
activity: 'Deaths, kills, skill gains, quests, logins.',
champs: 'The live champion / mini-champ / sea-boss board.',
guilds: 'Guild rosters, alliances and leaders.',
governors: 'City Loyalty governors, elections and term history.',
houses: 'Houses in danger (IDOC). Owner and price are separate fields below.',
presence: 'Population aggregate and the staff-online widget.',
ruleset: 'Skill/stat caps, house limits, vet rewards and the rest of the ruleset.',
atlas: 'The spawn atlas and bestiary. Static shard content, not live state.',
leaderboards: 'Point and loyalty standings across every points system.',
market: 'The shard-wide player-vendor index.',
}
const FIELD_LABEL = {
owner: 'House owner',
price: 'House price',
location: 'In-game location (map + coordinates)',
connect: 'Server connect address',
// Keyed on the WIRE field, which for a leaderboard entry is `name` — the
// projection matches literal JSON keys, so the rule cannot be spelled after the
// field's meaning. The label is what carries the meaning to the admin.
name: 'Character names on leaderboards',
ownerName: 'Vendor owner name',
// One rule, one key — `location` is a nested object on both the wire frame and
// the stored read model precisely so that hiding it takes the facet, the
// coordinates, the region and the house together.
ownerSerial: 'Vendor owner character id',
}
function RungSelect({ value, onChange, ladder, disabled }) {
return (
<select
className="input"
value={value}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
style={{ maxWidth: 200 }}
>
{ladder.map((rung) => (
<option key={rung} value={rung}>
{RUNG_LABEL[rung] || rung}
</option>
))}
</select>
)
}
function FeatureRow({ name, settings, defaults, ladder, onPatch }) {
const fields = Object.entries(settings.fields || {})
const changed =
defaults &&
(settings.enabled !== defaults.enabled ||
settings.audience !== defaults.audience ||
settings.stream !== defaults.stream ||
JSON.stringify(settings.fields) !== JSON.stringify(defaults.fields))
return (
<div
style={{
border: '1px solid var(--line)',
borderRadius: 10,
padding: 16,
display: 'flex',
flexDirection: 'column',
gap: 12,
opacity: settings.enabled ? 1 : 0.62,
}}
>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
<div style={{ minWidth: 0 }}>
<h3 className="display" style={{ margin: 0, fontSize: '1rem', color: 'var(--head)' }}>
{FEATURE_LABEL[name] || name}
{changed && (
<span
className="sans"
style={{ marginLeft: 8, fontSize: '0.62rem', letterSpacing: '0.06em', textTransform: 'uppercase', color: 'var(--accent)' }}
>
changed
</span>
)}
</h3>
<p className="sans" style={{ margin: '4px 0 0', fontSize: '0.82rem', color: 'var(--muted)', lineHeight: 1.5 }}>
{FEATURE_HINT[name]}
</p>
</div>
<label
className="sans"
style={{ flex: 'none', display: 'inline-flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: '0.86rem', color: 'var(--ink)' }}
>
<input
type="checkbox"
checked={settings.enabled}
onChange={(e) => onPatch(name, { enabled: e.target.checked })}
/>
Enabled
</label>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 20, alignItems: 'flex-end' }}>
<label style={{ display: 'block' }}>
<span className="field-label">Who can see it</span>
<RungSelect
value={settings.audience}
ladder={ladder}
disabled={!settings.enabled}
onChange={(audience) => onPatch(name, { audience })}
/>
<span className="sans dim" style={{ display: 'block', marginTop: 4, fontSize: '0.75rem' }}>
{RUNG_HINT[settings.audience]}
</span>
</label>
<label
className="sans"
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: '0.86rem', color: 'var(--ink)', paddingBottom: 22 }}
>
<input
type="checkbox"
checked={settings.stream}
disabled={!settings.enabled}
onChange={(e) => onPatch(name, { stream: e.target.checked })}
/>
Live updates
</label>
</div>
{fields.length > 0 && (
<div style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 12 }}>
<span className="field-label" style={{ display: 'block', marginBottom: 8 }}>
Sensitive fields
</span>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 16 }}>
{fields.map(([field, rung]) => (
<label key={field} style={{ display: 'block' }}>
<span className="sans dim" style={{ display: 'block', fontSize: '0.78rem', marginBottom: 4 }}>
{FIELD_LABEL[field] || field}
</span>
<RungSelect
value={rung}
ladder={ladder}
disabled={!settings.enabled}
onChange={(level) =>
onPatch(name, { fieldRules: { ...settings.fields, [field]: level } })
}
/>
</label>
))}
</div>
</div>
)}
</div>
)
}
export default function ShardVisibility() {
const [config, setConfig] = useState(null)
const [defaults, setDefaults] = useState(null)
const [ladder, setLadder] = useState([])
const [lockedFields, setLockedFields] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [saving, setSaving] = useState(false)
const [msg, setMsg] = useState('')
const load = useCallback(async () => {
setLoading(true)
setError('')
try {
const data = await api.admin.getShardVisibility()
setConfig(data.features)
setDefaults(data.defaults)
setLadder(data.ladder || [])
setLockedFields(data.lockedFields || [])
} catch (err) {
setError(err.message || 'Could not load visibility settings.')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
load()
}, [load])
function patch(name, changes) {
setMsg('')
setConfig((prev) => {
const next = { ...prev[name], ...changes }
// `fieldRules` in the API is `fields` in the effective config.
if (changes.fieldRules) {
next.fields = changes.fieldRules
delete next.fieldRules
}
return { ...prev, [name]: next }
})
}
async function save() {
setSaving(true)
setMsg('')
setError('')
try {
const body = {}
for (const [name, s] of Object.entries(config)) {
body[name] = {
enabled: s.enabled,
audience: s.audience,
stream: s.stream,
fieldRules: s.fields || {},
}
}
const data = await api.admin.saveShardVisibility(body)
setConfig(data.features)
setMsg('Saved. Changes take effect within a few seconds, including on open live streams.')
} catch (err) {
setError(err.message || 'Could not save.')
} finally {
setSaving(false)
}
}
function resetToDefaults() {
setMsg('')
setConfig(structuredClone(defaults))
}
if (loading) return <Loading />
if (error && !config) return <ErrorState message={error} onRetry={load} />
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
<header>
<h2 className="display" style={{ margin: 0, fontSize: '1.3rem', color: 'var(--head)' }}>
Shard visibility
</h2>
<p className="sans" style={{ margin: '6px 0 0', color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6, maxWidth: 760 }}>
Choose who can see each shard surface on the public site, and how much detail they get.
Turning a feature off hides it entirely its pages return not found rather than
revealing that it exists. Live updates controls whether the feature streams changes in
real time; the pages still work without it, they just refresh on load.
</p>
{lockedFields.length > 0 && (
<p className="sans dim" style={{ margin: '8px 0 0', fontSize: '0.82rem', lineHeight: 1.6, maxWidth: 760 }}>
Not configurable: <strong style={{ color: 'var(--ink)' }}>{lockedFields.join(', ')}</strong>
game account names and website user ids are never shown below admin, on any surface. They
arent visible in game either, so publishing them would disclose something the shard
itself doesnt.
</p>
)}
</header>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{Object.entries(config).map(([name, settings]) => (
<FeatureRow
key={name}
name={name}
settings={settings}
defaults={defaults?.[name]}
ladder={ladder}
onPatch={patch}
/>
))}
</div>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={save} disabled={saving} className="btn btn-primary btn-sq">
{saving ? 'Saving…' : 'Save changes'}
</button>
<button onClick={resetToDefaults} disabled={saving} className="btn btn-sq">
Restore defaults
</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>
</div>
)
}

View File

@@ -0,0 +1,285 @@
import { useCallback, useEffect, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js'
// ── Admin · Spawn atlas ─────────────────────────────────────────────────────
//
// The atlas re-derives itself from the shard's ServUO tree on every boot, so
// this panel exists for the three things a restart cannot do:
//
// • point it at a different tree,
// • apply a map change without restarting, and
// • answer a refresh that was parsed but deliberately NOT applied because it
// would remove a facet.
//
// That last one is the reason the panel is worth building. Losing a facet looks
// exactly like a half-copied or mid-update tree, and boot cannot tell them
// apart — so it stages the decision for a human instead of guessing. Until
// someone decides here, the site keeps serving the atlas it already had.
// A refresh reports its outcome rather than throwing (the boot path must never
// be stopped by a bad tree), so these are answers, not errors — the panel says
// what happened in the shard's terms instead of showing a failure box.
const OUTCOME = {
imported: (r) =>
`Imported — ${r.counts?.points?.toLocaleString() ?? '?'} spawners, ${r.counts?.creatures?.toLocaleString() ?? '?'} creatures.`,
unchanged: (r) =>
r.reason === 'refresh previously rejected'
? 'Unchanged — this exact tree was already reviewed and declined.'
: 'Unchanged — the tree matches what is already loaded.',
needsReview: () => 'Staged for review: this refresh would remove a facet, so it was not applied.',
unavailable: (r) => `The tree could not be read: ${r.reason || 'unknown reason'}`,
skipped: () => 'No ServUO path is configured, so there is nothing to import.',
failed: (r) => `Refresh failed: ${r.reason || 'unknown reason'}`,
rejected: () => 'Declined. It will not be offered again until the tree changes.',
}
const describe = (result) => (OUTCOME[result?.status] || (() => `Result: ${result?.status}`))(result)
function Row({ label, children }) {
return (
<div
className="sans"
style={{
display: 'flex',
alignItems: 'baseline',
justifyContent: 'space-between',
gap: 16,
padding: '7px 0',
borderBottom: '1px solid var(--line)',
fontSize: '0.86rem',
}}
>
<span className="dim">{label}</span>
<span style={{ color: 'var(--head)', textAlign: 'right', wordBreak: 'break-all' }}>{children}</span>
</div>
)
}
function PendingReview({ pending, busy, onApprove, onReject }) {
const declined = pending.status === 'rejected'
return (
<section
style={{
border: `1px solid ${declined ? 'var(--line)' : '#c58f4a'}`,
borderRadius: 10,
padding: 16,
background: declined ? 'transparent' : 'rgba(197,143,74,0.08)',
}}
>
<h3 className="display" style={{ margin: 0, fontSize: '1rem', color: 'var(--head)' }}>
{declined ? 'A refresh was declined' : 'A refresh is waiting for you'}
</h3>
<p className="sans" style={{ margin: '6px 0 12px', fontSize: '0.86rem', color: 'var(--muted)', lineHeight: 1.6 }}>
{declined ? (
<>
This tree was reviewed and declined, so it is not offered again until the files change.
Approving now applies it anyway.
</>
) : (
<>
The tree parses cleanly but would <strong>remove {pending.removedFacets?.length || 0} facet
</strong>
{(pending.removedFacets?.length || 0) === 1 ? '' : 's'} the site is currently serving. That
is what a half-copied or mid-update tree looks like as well as a real map change, so it was
not applied. Approving re-parses the tree as it is right now if you have since fixed the
mount, what lands is the corrected import.
</>
)}
</p>
<Row label="Would remove">{(pending.removedFacets || []).join(', ') || '—'}</Row>
<Row label="Would add">{(pending.addedFacets || []).join(', ') || '—'}</Row>
<Row label="Detected">{pending.detectedAt ? new Date(pending.detectedAt).toLocaleString() : '—'}</Row>
<div style={{ display: 'flex', gap: 10, marginTop: 14, flexWrap: 'wrap' }}>
<button type="button" className="btn btn-primary btn-sq" disabled={busy} onClick={onApprove}>
Approve and import
</button>
{!declined && (
<button type="button" className="btn btn-sq" disabled={busy} onClick={onReject}>
Keep the current atlas
</button>
)}
</div>
</section>
)
}
export default function SpawnAtlas() {
const [status, setStatus] = useState(null)
const [path, setPath] = useState('')
const [force, setForce] = useState(false)
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [msg, setMsg] = useState('')
const load = useCallback(async () => {
setLoading(true)
setError('')
try {
const data = await api.admin.atlas.status()
setStatus(data)
setPath(data.path || '')
} catch (err) {
setError(err.message || 'Could not load atlas status.')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
load()
}, [load])
// Every mutating action shares this: run it, report what it said, then reload
// status so the panel reflects the world rather than what we assumed happened.
async function run(action, fn) {
setBusy(true)
setMsg('')
setError('')
try {
const result = await fn()
setMsg(describe(result))
const fresh = await api.admin.atlas.status()
setStatus(fresh)
setPath(fresh.path || '')
} catch (err) {
setError(err.message || `Could not ${action}.`)
} finally {
setBusy(false)
}
}
async function savePath() {
setBusy(true)
setMsg('')
setError('')
try {
const fresh = await api.admin.atlas.setPath(path.trim())
setStatus(fresh)
setPath(fresh.path || '')
setMsg(
fresh.path === ''
? 'Path cleared. The atlas will be skipped on the next boot; what is loaded keeps serving.'
: fresh.treeReadable
? 'Saved. The tree is readable — import when you are ready.'
: 'Saved, but the tree could not be read from here. Check the mount and permissions.',
)
} catch (err) {
setError(err.message || 'Could not save the path.')
} finally {
setBusy(false)
}
}
if (loading) return <Loading />
if (error && !status) return <ErrorState message={error} />
const counts = status?.counts || null
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
<header>
<h2 className="display" style={{ margin: 0, fontSize: '1.3rem', color: 'var(--head)' }}>
Spawn atlas
</h2>
<p className="sans" style={{ margin: '6px 0 0', color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6, maxWidth: 760 }}>
The bestiary and spawn map on the public site, parsed from the shards own ServUO files.
It refreshes itself on every server start; everything here is for the times you dont want
to wait for one. Nothing on this page touches the sidecar the atlas is shard content, not
shard state, and stays complete while the shard is down.
</p>
</header>
{status?.pending && (
<PendingReview
pending={status.pending}
busy={busy}
onApprove={() => run('approve the refresh', () => api.admin.atlas.approve())}
onReject={() => run('decline the refresh', () => api.admin.atlas.reject())}
/>
)}
<section style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}>
<h3 className="display" style={{ margin: '0 0 10px', fontSize: '1rem', color: 'var(--head)' }}>
What is loaded
</h3>
<Row label="Imported">
{status?.importedAt ? new Date(status.importedAt).toLocaleString() : 'Never'}
</Row>
<Row label="Facets">{status?.facets?.length ? status.facets.join(', ') : '—'}</Row>
{counts && (
<>
<Row label="Spawners">{counts.points?.toLocaleString() ?? '—'}</Row>
<Row label="Creatures">{counts.creatures?.toLocaleString() ?? '—'}</Row>
<Row label="Regions / landmarks">
{`${counts.regions?.toLocaleString() ?? '—'} / ${counts.landmarks?.toLocaleString() ?? '—'}`}
</Row>
<Row label="Champion altars">{counts.champions?.toLocaleString() ?? '—'}</Row>
</>
)}
<Row label="Tree readable">
{!status?.configured ? 'No path set' : status.treeReadable ? 'Yes' : 'No'}
</Row>
<Row label="Tree changed since import">
{status?.drift == null ? '—' : status.drift ? 'Yes — an import would pick it up' : 'No'}
</Row>
</section>
<section style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}>
<h3 className="display" style={{ margin: '0 0 4px', fontSize: '1rem', color: 'var(--head)' }}>
ServUO tree
</h3>
<p className="sans" style={{ margin: '0 0 12px', fontSize: '0.84rem', color: 'var(--muted)', lineHeight: 1.6 }}>
Where the website reads the shards spawn files from the same host, a bind mount or a
shared volume. This setting wins over the <code>SERVUO_PATH</code> deploy default, so the
mount can move without a redeploy. Leave it blank to turn the atlas off.
</p>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center' }}>
<input
className="input"
value={path}
onChange={(e) => setPath(e.target.value)}
placeholder="/srv/servuo"
style={{ flex: '1 1 320px', minWidth: 0 }}
/>
<button type="button" className="btn btn-sq" disabled={busy} onClick={savePath}>
Save path
</button>
</div>
</section>
<section style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}>
<h3 className="display" style={{ margin: '0 0 4px', fontSize: '1rem', color: 'var(--head)' }}>
Re-import
</h3>
<p className="sans" style={{ margin: '0 0 12px', fontSize: '0.84rem', color: 'var(--muted)', lineHeight: 1.6 }}>
Applies a map change without restarting. An unchanged tree costs nothing the source files
are hashed first and skipped when they match. A refresh that would remove a facet still
comes back here for approval rather than being applied.
</p>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
<button
type="button"
className="btn btn-primary btn-sq"
disabled={busy || !status?.configured}
onClick={() => run('import the atlas', () => api.admin.atlas.import(force))}
>
{busy ? 'Working…' : 'Import now'}
</button>
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: '0.85rem', cursor: 'pointer' }}>
<input type="checkbox" checked={force} onChange={(e) => setForce(e.target.checked)} />
Re-import even if the tree is unchanged
</label>
</div>
</section>
{(msg || error) && (
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
{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>
)}
</div>
)
}

View File

@@ -1,4 +1,4 @@
import { useMemo } from 'react' import { useCallback, useEffect, useMemo, 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'
@@ -84,6 +84,38 @@ function Standing({ scope }) {
) )
} }
// 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). // Houses owned by the user's accounts, IDOC first (flagged).
function Houses({ scope }) { function Houses({ scope }) {
const { data } = useAsync(() => scope.houses(), [scope]) const { data } = useAsync(() => scope.houses(), [scope])
@@ -96,31 +128,118 @@ function Houses({ scope }) {
) : ( ) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}> <ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
{data.map((h) => ( {data.map((h) => (
<li <HouseRow key={h.serial} house={h} />
key={h.serial} ))}
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)' }} </ul>
> )}
<div style={{ minWidth: 0 }}> </section>
<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>}
// 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.
function SecurityAdmin({ userId }) {
const [devices, setDevices] = useState(null)
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const load = useCallback(async () => {
try {
setDevices(await api.admin.userTrustedDevices(userId))
} catch {
setError('Could not load trusted devices.')
}
}, [userId])
useEffect(() => {
load()
}, [load])
async function revoke(deviceId) {
setBusy(true); setMsg(''); setError('')
try {
await api.admin.revokeUserTrustedDevice(userId, deviceId)
await load()
} catch {
setError('Could not revoke that device.')
} finally {
setBusy(false)
}
}
async function revokeAll() {
if (!window.confirm('Revoke ALL of this users trusted devices?')) return
setBusy(true); setMsg(''); setError('')
try {
await api.admin.revokeAllUserTrustedDevices(userId)
setMsg('All trusted devices revoked.')
await load()
} catch {
setError('Could not revoke devices.')
} finally {
setBusy(false)
}
}
async function resetMfa() {
if (!window.confirm('Reset this users two-factor? This turns TOTP off, revokes their trusted devices, and clears their recovery codes so they can sign in with their password.')) return
setBusy(true); setMsg(''); setError('')
try {
await api.admin.resetUserMfa(userId)
setMsg('Two-factor has been reset for this user.')
await load()
} catch {
setError('Could not reset two-factor.')
} finally {
setBusy(false)
}
}
const fmt = (d) => {
const t = d ? new Date(d) : null
return t && !Number.isNaN(t.getTime()) ? t.toLocaleDateString() : '—'
}
return (
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
<SectionTitle>Security &amp; two-factor</SectionTitle>
{devices == null ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>Loading</p>
) : devices.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No trusted devices.</p>
) : (
<ul style={{ listStyle: 'none', margin: '0 0 14px', padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
{devices.map((d) => (
<li key={d.id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem' }}>
{d.deviceName || (d.platform === 'mobile' ? 'Mobile app' : 'Browser')}
</div> </div>
<div className="sans dim" style={{ fontSize: '0.78rem', marginTop: 2 }}> <div className="sans dim" style={{ fontSize: '0.76rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{h.region || (h.map != null ? `map ${h.map}` : 'unknown')} {d.userAgent || '—'} · last used {fmt(d.lastUsedAt)} · expires {fmt(d.expiresAt)}
{h.x != null ? ` · ${h.x}, ${h.y}` : ''}
{h.ownerAcct ? ` · ${h.ownerAcct}` : ''}
{(h.coOwners || h.friends) ? ` · ${h.coOwners || 0} co-owners, ${h.friends || 0} friends` : ''}
</div> </div>
</div> </div>
<div className="sans dim" style={{ flex: 'none', fontSize: '0.78rem', textAlign: 'right' }}> <button onClick={() => revoke(d.id)} disabled={busy} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
{(h.decay || h.stage) ? <div style={{ color: h.isIdoc ? '#e0928a' : 'var(--muted)' }}>{h.decay || h.stage}</div> : null} Revoke
{h.price != null ? <div style={{ fontVariantNumeric: 'tabular-nums' }}>{Number(h.price).toLocaleString()} gp</div> : null} </button>
{h.lastRefreshed ? <div>refreshed {ago(h.lastRefreshed)}</div> : null}
</div>
</li> </li>
))} ))}
</ul> </ul>
)} )}
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
{devices && devices.length > 0 && (
<button onClick={revokeAll} disabled={busy} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
Revoke all trusted devices
</button>
)}
<button onClick={resetMfa} disabled={busy} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>
Reset two-factor
</button>
</div>
{msg && <p className="sans" style={{ marginTop: 12, color: '#7fd0a4', fontSize: '0.86rem' }}>{msg}</p>}
{error && <p className="sans" style={{ marginTop: 12, color: '#d98b84', fontSize: '0.86rem' }}>{error}</p>}
</section> </section>
) )
} }
@@ -176,6 +295,7 @@ export default function UserDetail() {
</div> </div>
</div> </div>
<SecurityAdmin userId={id} />
<ShardSections scope={scope} /> <ShardSections scope={scope} />
</section> </section>
) )

View File

@@ -111,6 +111,9 @@ export default function WikiEditor({ slug, onClose, onSaved }) {
} }
} }
let saveLabel = form.published ? 'Save & publish' : 'Save draft'
if (busy) saveLabel = 'Saving…'
return ( return (
<> <>
<Modal <Modal
@@ -133,7 +136,7 @@ export default function WikiEditor({ slug, onClose, onSaved }) {
Cancel Cancel
</button> </button>
<button onClick={save} disabled={busy || loading} className="btn btn-primary btn-sq"> <button onClick={save} disabled={busy || loading} className="btn btn-primary btn-sq">
{busy ? 'Saving…' : form.published ? 'Save & publish' : 'Save draft'} {saveLabel}
</button> </button>
</> </>
} }

View File

@@ -80,11 +80,11 @@ export default function WikiHistory({ slug, onClose, onRestored }) {
</> </>
} }
> >
{loading ? ( {loading && <span className="spin" />}
<span className="spin" /> {!loading && error && (
) : error ? (
<p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{error}</p> <p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>
) : ( )}
{!loading && !error && (
<div className="wiki-history"> <div className="wiki-history">
<ul className="wiki-history-list"> <ul className="wiki-history-list">
{revisions.map((r, i) => ( {revisions.map((r, i) => (
@@ -122,11 +122,16 @@ export default function WikiHistory({ slug, onClose, onRestored }) {
{parts.length === 0 || (parts.length === 1 && !parts[0].added && !parts[0].removed) ? ( {parts.length === 0 || (parts.length === 1 && !parts[0].added && !parts[0].removed) ? (
<span className="muted">No textual differences.</span> <span className="muted">No textual differences.</span>
) : ( ) : (
parts.map((p, i) => ( parts.map((p, i) => {
<span key={i} className={p.added ? 'diff-add' : p.removed ? 'diff-del' : ''}> let cls = ''
{p.value} if (p.added) cls = 'diff-add'
</span> else if (p.removed) cls = 'diff-del'
)) return (
<span key={`${i}:${p.value}`} className={cls}>
{p.value}
</span>
)
})
)} )}
</div> </div>
</> </>

View File

@@ -14,7 +14,7 @@ export default function AcceptInvite() {
const navigate = useNavigate() const navigate = useNavigate()
const { refresh } = useAuth() const { refresh } = useAuth()
const [invite, setInvite] = useState(null) // { email, role } const [invite, setInvite] = useState(null) // fields email and role
const [loadErr, setLoadErr] = useState('') const [loadErr, setLoadErr] = useState('')
const [signupOk, setSignupOk] = useState(false) const [signupOk, setSignupOk] = useState(false)

View File

@@ -1,6 +1,9 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import ProviderIcon from '../../components/ProviderIcon.jsx' import ProviderIcon from '../../components/ProviderIcon.jsx'
import { Loading, ErrorState } from '../../components/PageState.jsx' import { Loading, ErrorState } from '../../components/PageState.jsx'
import RecoveryCodesDisplay from '../../components/security/RecoveryCodesDisplay.jsx'
import TrustedDevicesPanel from '../../components/security/TrustedDevicesPanel.jsx'
import RecoveryCodesPanel from '../../components/security/RecoveryCodesPanel.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx' import { useAuth } from '../../contexts/AuthContext.jsx'
import { api } from '../../api/client.js' import { api } from '../../api/client.js'
@@ -75,6 +78,9 @@ function ChangePassword({ account }) {
} }
} }
let pwLabel = hasPassword ? 'Change password' : 'Set password'
if (busy) pwLabel = 'Saving…'
return ( return (
<Section title={hasPassword ? 'Password' : 'Set a password'}> <Section title={hasPassword ? 'Password' : 'Set a password'}>
{!hasPassword && ( {!hasPassword && (
@@ -96,7 +102,7 @@ function ChangePassword({ account }) {
</label> </label>
<div> <div>
<button type="submit" disabled={busy} className="btn btn-primary btn-sq"> <button type="submit" disabled={busy} className="btn btn-primary btn-sq">
{busy ? 'Saving…' : hasPassword ? 'Change password' : 'Set password'} {pwLabel}
</button> </button>
</div> </div>
<Note msg={msg} error={error} /> <Note msg={msg} error={error} />
@@ -113,6 +119,7 @@ function TwoFactor({ account, reload }) {
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('') const [msg, setMsg] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
const [newCodes, setNewCodes] = useState(null) // one-time recovery codes shown after enabling
async function begin() { async function begin() {
setBusy(true); setMsg(''); setError('') setBusy(true); setMsg(''); setError('')
@@ -128,8 +135,8 @@ function TwoFactor({ account, reload }) {
async function confirm() { async function confirm() {
setBusy(true); setMsg(''); setError('') setBusy(true); setMsg(''); setError('')
try { try {
await api.player.totpEnable(code.trim()) const res = await api.player.totpEnable(code.trim())
setSetup(null); setCode(''); setMsg('Two-factor is now enabled.') setSetup(null); setCode(''); setNewCodes(res?.recoveryCodes || null); setMsg('Two-factor is now enabled.')
await reload() await reload()
} catch (err) { } catch (err) {
setError(err.message || 'Could not enable two-factor.') setError(err.message || 'Could not enable two-factor.')
@@ -201,6 +208,11 @@ function TwoFactor({ account, reload }) {
</div> </div>
)} )}
<Note msg={msg} error={error} /> <Note msg={msg} error={error} />
{newCodes && (
<div style={{ marginTop: 16 }}>
<RecoveryCodesDisplay codes={newCodes} onDone={() => setNewCodes(null)} />
</div>
)}
</Section> </Section>
) )
} }
@@ -413,6 +425,12 @@ export default function PlayerAccount() {
<ChangeUsername account={account} onChanged={onUsernameChanged} /> <ChangeUsername account={account} onChanged={onUsernameChanged} />
<ChangePassword account={account} /> <ChangePassword account={account} />
<TwoFactor account={account} reload={load} /> <TwoFactor account={account} reload={load} />
{account.totp_enabled && (
<>
<TrustedDevicesPanel />
<RecoveryCodesPanel hasPassword={account.has_password !== false} />
</>
)}
<LinkedAccounts /> <LinkedAccounts />
<ActiveDevices /> <ActiveDevices />
</> </>

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 ProviderIcon from '../../components/ProviderIcon.jsx' import ProviderIcon from '../../components/ProviderIcon.jsx'
import TrustLimitModal from '../../components/security/TrustLimitModal.jsx'
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'
@@ -34,6 +35,12 @@ export default function PlayerLogin() {
const [challenge, setChallenge] = useState('') const [challenge, setChallenge] = useState('')
const [code, setCode] = useState('') const [code, setCode] = useState('')
const [ssoTotp, setSsoTotp] = useState(false) const [ssoTotp, setSsoTotp] = useState(false)
const [trustDevice, setTrustDevice] = useState(false)
const [useRecovery, setUseRecovery] = useState(false)
// When trust was requested at login but the device cap is reached: show the
// revoke-to-continue modal, then navigate on resolve. `pendingDest` holds where
// to go once the prompt is dealt with.
const [trustLimit, setTrustLimit] = useState(null) // { devices, dest }
const [providers, setProviders] = useState([]) const [providers, setProviders] = useState([])
const [canRegister, setCanRegister] = useState(false) const [canRegister, setCanRegister] = useState(false)
@@ -101,22 +108,48 @@ 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 u = await loginTotp(challenge, code) const entered = code.trim()
navigate(destFor(u), { replace: true }) const data = await loginTotp(challenge, useRecovery ? '' : entered, {
recoveryCode: useRecovery ? entered : undefined,
trustDevice,
})
const to = destFor(data.user)
// Trust was requested but the device cap is reached: the session is already
// issued, so prompt to revoke one before trusting, then navigate.
if (data.trustLimitReached) {
setTrustLimit({ devices: data.devices || [], dest: to })
setBusy(false)
return
}
navigate(to, { replace: true })
} }
} catch (err) { } catch (err) {
const expired = err.status === 401 && /expired/i.test(err.message) const expired = err.status === 401 && /expired/i.test(err.message)
setError(expired ? 'Your verification session expired. Please sign in again.' : 'Invalid verification code.') const badRecovery = useRecovery ? 'That recovery code is not valid.' : 'Invalid verification code.'
setError(expired ? 'Your verification session expired. Please sign in again.' : badRecovery)
setBusy(false) setBusy(false)
if (expired) { if (expired) {
setStage('creds') setStage('creds')
@@ -125,6 +158,10 @@ export default function PlayerLogin() {
} }
} }
let submitLabel = 'Sign in'
if (busy) submitLabel = 'Signing in…'
else if (stage === 'totp') submitLabel = 'Verify'
return ( return (
<PlayerShell <PlayerShell
subtitle="Player sign-in" subtitle="Player sign-in"
@@ -165,13 +202,43 @@ export default function PlayerLogin() {
</div> </div>
</> </>
) : ( ) : (
<label style={{ display: 'block', marginBottom: 22 }}> <>
<span className="field-label">Authentication code</span> <label style={{ display: 'block', marginBottom: 14 }}>
<input type="text" inputMode="numeric" autoComplete="one-time-code" autoFocus placeholder="6-digit code" value={code} onChange={(e) => setCode(e.target.value)} className="input" /> <span className="field-label">{useRecovery ? 'Recovery code' : 'Authentication code'}</span>
<span className="sans" style={{ display: 'block', marginTop: 8, color: 'var(--dim)', fontSize: '0.76rem' }}> <input
Enter the code from your authenticator app. type="text"
</span> inputMode={useRecovery ? 'text' : 'numeric'}
</label> autoComplete="one-time-code"
autoFocus
placeholder={useRecovery ? 'xxxxx-xxxxx' : '6-digit code'}
value={code}
onChange={(e) => setCode(e.target.value)}
className="input"
/>
<span className="sans" style={{ display: 'block', marginTop: 8, color: 'var(--dim)', fontSize: '0.76rem' }}>
{useRecovery ? 'Enter one of your saved single-use recovery codes.' : 'Enter the code from your authenticator app.'}
</span>
</label>
{/* Offered on the SSO second factor too — the trust is on the device,
not on how the first factor was proved. Inside the app's Custom Tab
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' }}>
<input type="checkbox" checked={trustDevice} onChange={(e) => setTrustDevice(e.target.checked)} />
Trust this device for 30 days (skip the code next time)
</label>
{/* Recovery codes remain password-login only: the SSO second step
verifies an authenticator code against the staged challenge. */}
{!ssoTotp && (
<button
type="button"
onClick={() => { setUseRecovery((v) => !v); setCode('') }}
className="sans"
style={{ display: 'block', marginBottom: 22, background: 'none', border: 'none', padding: 0, color: 'var(--accent)', cursor: 'pointer', fontSize: '0.8rem' }}
>
{useRecovery ? 'Use an authenticator code instead' : 'Use a recovery code instead'}
</button>
)}
</>
)} )}
{(error || (stage === 'creds' && ssoError)) && ( {(error || (stage === 'creds' && ssoError)) && (
@@ -181,7 +248,7 @@ export default function PlayerLogin() {
)} )}
<button type="submit" disabled={busy} className="btn btn-primary" style={{ display: 'block', width: '100%', borderRadius: 8, padding: 12, textAlign: 'center' }}> <button type="submit" disabled={busy} className="btn btn-primary" style={{ display: 'block', width: '100%', borderRadius: 8, padding: 12, textAlign: 'center' }}>
{busy ? 'Signing in…' : stage === 'totp' ? 'Verify' : 'Sign in'} {submitLabel}
</button> </button>
{stage === 'creds' && providers.length > 0 && ( {stage === 'creds' && providers.length > 0 && (
@@ -204,6 +271,14 @@ export default function PlayerLogin() {
</div> </div>
)} )}
</form> </form>
{trustLimit && (
<TrustLimitModal
devices={trustLimit.devices}
onTrusted={() => navigate(trustLimit.dest, { replace: true })}
onCancel={() => navigate(trustLimit.dest, { replace: true })}
/>
)}
</PlayerShell> </PlayerShell>
) )
} }

View File

@@ -1,7 +1,11 @@
import { useMemo } 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'
// 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
@@ -30,7 +34,11 @@ const IconUser = () => <Icon><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0
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;
// the editor may only relabel, reorder and hide what it finds (§7). No row
// carries a gate — every player sees all three — so the merged result is what
// renders, with no filter after it.
export const NAV = [
{ to: '/player', label: 'Characters', end: true, icon: IconUser }, { to: '/player', label: 'Characters', end: true, icon: IconUser },
{ 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 },
@@ -60,6 +68,8 @@ 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 nav = useMemo(() => applyNavOverrides(NAV, navOverrides.nav_player), [navOverrides.nav_player])
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const title = const title =
@@ -86,6 +96,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 +109,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}

View File

@@ -75,15 +75,17 @@ export default function PlayerRegister() {
</p> </p>
} }
> >
{avail === null ? ( {avail === null && (
<div style={{ display: 'grid', placeItems: 'center', padding: 20 }}> <div style={{ display: 'grid', placeItems: 'center', padding: 20 }}>
<span className="spin" /> <span className="spin" />
</div> </div>
) : closed ? ( )}
{avail !== null && closed && (
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.9rem', textAlign: 'center', lineHeight: 1.6 }}> <p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.9rem', textAlign: 'center', lineHeight: 1.6 }}>
Self-registration is currently closed. Please check back later. Self-registration is currently closed. Please check back later.
</p> </p>
) : ( )}
{avail !== null && !closed && (
<> <>
{avail.password && ( {avail.password && (
<form onSubmit={onSubmit}> <form onSubmit={onSubmit}>

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

@@ -0,0 +1,310 @@
import { useCallback, useEffect, 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, EmptyState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { api } from '../../api/client.js'
// ── The spawn atlas ─────────────────────────────────────────────────────────
//
// What the shard CONTAINS, as opposed to what it is doing: which creatures
// spawn, where, and which champion altars are configured. There is no live feed
// here and no `connected` indicator, deliberately — this is parsed from the
// shard's own files and stays complete while the shard is down.
//
// Facet names come from the shard's data, never from a list in this file. A
// shard running custom maps gets its own names in the filter with no code
// change (docs/link/v3.md §6.1 R2).
const PAGE = 50
const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : '—')
const TABS = [
{ key: 'creatures', label: 'Creatures' },
{ key: 'champions', label: 'Champion altars' },
{ key: 'places', label: 'Places' },
]
function Chip({ active, onClick, children }) {
return (
<button
type="button"
onClick={onClick}
className="sans"
style={{
fontSize: '0.78rem',
padding: '5px 12px',
borderRadius: 999,
cursor: 'pointer',
color: active ? 'var(--bg-deep)' : 'var(--muted)',
background: active ? 'var(--accent)' : 'transparent',
border: `1px solid ${active ? 'var(--accent)' : 'var(--line)'}`,
}}
>
{children}
</button>
)
}
function CreatureCard({ creature }) {
const facets = Object.entries(creature.facets || {}).sort((a, b) => b[1] - a[1])
return (
<Link
to={`/site/atlas/${encodeURIComponent(creature.slug)}`}
className="panel"
style={{
padding: '13px 15px',
display: 'flex',
alignItems: 'center',
gap: 14,
textDecoration: 'none',
color: 'inherit',
}}
>
<div style={{ minWidth: 0, flex: 1 }}>
<div
className="display"
style={{
fontSize: '0.98rem',
color: 'var(--head)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{creature.name}
</div>
<div className="sans dim" style={{ fontSize: '0.74rem', marginTop: 3 }}>
{facets.length === 0
? '—'
: facets.map(([facet, n]) => `${facet} (${n})`).join(' · ')}
</div>
</div>
<div className="sans" style={{ flex: 'none', textAlign: 'right' }}>
<div style={{ color: 'var(--head)', fontSize: '0.92rem' }}>{num(creature.total)}</div>
<div className="dim" style={{ fontSize: '0.68rem', letterSpacing: '0.05em' }}>
{num(creature.points)} spawners
</div>
</div>
</Link>
)
}
// The creature list owns its own paging rather than going through useAsync: a
// "load more" appends to what is already on screen, which a hook that resets to
// `{ loading: true, data: null }` on every dependency change cannot express.
function Creatures({ q, facet }) {
const [state, setState] = useState({ loading: true, error: null, items: [], total: 0 })
const [more, setMore] = useState(false)
const load = useCallback(
async (offset) => {
const page = await api.atlas.creatures({ q, facet, limit: PAGE, offset })
return page
},
[q, facet],
)
useEffect(() => {
let alive = true
setState({ loading: true, error: null, items: [], total: 0 })
load(0)
.then((page) => {
if (alive) setState({ loading: false, error: null, items: page.creatures || [], total: page.total || 0 })
})
.catch((error) => alive && setState({ loading: false, error, items: [], total: 0 }))
return () => {
alive = false
}
}, [load])
const loadMore = async () => {
setMore(true)
try {
const page = await load(state.items.length)
setState((s) => ({ ...s, items: [...s.items, ...(page.creatures || [])], total: page.total ?? s.total }))
} catch {
// A failed "load more" leaves what is already on screen alone; the button
// simply stays available to retry.
} finally {
setMore(false)
}
}
if (state.loading) return <Loading />
if (state.error) return <ErrorState message="Could not load the bestiary right now." />
if (state.items.length === 0) {
return <EmptyState>Nothing in the atlas matches that.</EmptyState>
}
return (
<>
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 12px' }}>
Showing {num(state.items.length)} of {num(state.total)}
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{state.items.map((c) => (
<CreatureCard key={c.slug} creature={c} />
))}
</div>
{state.items.length < state.total && (
<div style={{ textAlign: 'center', marginTop: 16 }}>
<button type="button" className="btn" onClick={loadMore} disabled={more}>
{more ? 'Loading…' : 'Load more'}
</button>
</div>
)}
</>
)
}
// The CONFIGURED altar roster — where the altars are and what each summons. The
// live board ("it is on level 3 right now") is a different page, /site/champs,
// fed by the sidecar. Both exist; they are not the same thing.
function Champions({ facet }) {
const { loading, error, data } = useAsync(() => api.atlas.champions(facet), [facet])
if (loading) return <Loading />
if (error) return <ErrorState message="Could not load the champion altars right now." />
if (!data || data.length === 0) return <EmptyState>No champion altars are configured.</EmptyState>
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{data.map((champ) => (
<div key={champ.slug} className="panel" style={{ padding: '13px 15px', display: 'flex', gap: 14, alignItems: 'center' }}>
<div style={{ minWidth: 0, flex: 1 }}>
<div className="display" style={{ fontSize: '0.98rem', color: 'var(--head)' }}>
{champ.label || champ.name}
</div>
<div className="sans dim" style={{ fontSize: '0.74rem', marginTop: 3 }}>
{champ.facet}
{champ.group ? ` · ${champ.group}` : ''} · {champ.x}, {champ.y}
</div>
</div>
<span className="sans" style={{ flex: 'none', fontSize: '0.76rem', color: 'var(--muted)' }}>
{champ.randomType ? 'Random champion' : champ.type || '—'}
</span>
</div>
))}
</div>
)
}
// Regions and landmarks together: both answer "where is that?", and splitting
// them into two tabs would make the visitor guess which list a name lives in.
function Places({ q, facet }) {
const { loading, error, data } = useAsync(
() => Promise.all([api.atlas.regions({ q, facet }), api.atlas.landmarks({ q, facet })]),
[q, facet],
)
const rows = useMemo(() => {
if (!data) return []
const [regions, landmarks] = data
return [
...regions.map((r) => ({ key: `r:${r.facet}:${r.name}`, name: r.name, facet: r.facet, detail: r.parent || r.type || 'Region', kind: 'Region' })),
...landmarks.map((l) => ({ key: `l:${l.facet}:${l.group || ''}:${l.name}:${l.x}:${l.y}`, name: l.group ? `${l.group}${l.name}` : l.name, facet: l.facet, detail: `${l.x}, ${l.y}`, kind: 'Landmark' })),
].sort((a, b) => a.name.localeCompare(b.name))
}, [data])
if (loading) return <Loading />
if (error) return <ErrorState message="Could not load places right now." />
if (rows.length === 0) return <EmptyState>No regions or landmarks match that.</EmptyState>
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{rows.map((row) => (
<div key={row.key} className="panel" style={{ padding: '10px 14px', display: 'flex', gap: 12, alignItems: 'baseline' }}>
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--head)', fontSize: '0.88rem' }}>{row.name}</span>
<span className="sans dim" style={{ fontSize: '0.72rem' }}>{row.facet} · {row.detail}</span>
<span className="sans dim" style={{ fontSize: '0.66rem', letterSpacing: '0.06em', flex: 'none' }}>{row.kind}</span>
</div>
))}
</div>
)
}
export default function Atlas() {
const [tab, setTab] = useState('creatures')
const [input, setInput] = useState('')
const [q, setQ] = useState('')
const [facet, setFacet] = useState('')
const meta = useAsync(() => api.atlas.meta())
// Debounced: typing "lizardman" should be one request, not nine.
useEffect(() => {
const timer = setTimeout(() => setQ(input.trim()), 250)
return () => clearTimeout(timer)
}, [input])
const facets = meta.data?.facets || []
const counts = meta.data?.counts || null
const imported = meta.data?.importedAt ? new Date(meta.data.importedAt) : null
return (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<PageHeader
eyebrow="Bestiary"
title="Spawn atlas"
lead="Where everything lives, read straight out of the shard's own spawn files — so it stays accurate whether or not the server is up."
/>
{/* The atlas is only as good as its placement rate, so the page states
it rather than implying every spawner resolved to a named place. */}
{counts && (
<p className="sans dim" style={{ fontSize: '0.76rem', margin: '-12px 0 18px' }}>
{num(counts.creatures)} creatures across {num(counts.points)} spawners
{Number.isFinite(counts.unresolvedPoints) && counts.points
? ` · ${Math.round(((counts.points - counts.unresolvedPoints) / counts.points) * 100)}% placed to a named region or landmark`
: ''}
{imported ? ` · parsed ${imported.toLocaleDateString()}` : ''}
</p>
)}
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 12 }}>
{TABS.map((t) => (
<Chip key={t.key} active={tab === t.key} onClick={() => setTab(t.key)}>
{t.label}
</Chip>
))}
</div>
{tab !== 'champions' && (
<input
className="input"
type="search"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={tab === 'creatures' ? 'Search creatures…' : 'Search regions and landmarks…'}
style={{ width: '100%', marginBottom: 12 }}
/>
)}
{facets.length > 0 && (
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 18 }}>
<Chip active={facet === ''} onClick={() => setFacet('')}>
All facets
</Chip>
{facets.map((f) => (
<Chip key={f} active={facet === f} onClick={() => setFacet(f)}>
{f}
</Chip>
))}
</div>
)}
{meta.error && <ErrorState message="Could not load the atlas right now." />}
{!meta.error && !meta.loading && !imported && (
<EmptyState>The spawn atlas has not been imported yet.</EmptyState>
)}
{!meta.error && imported && (
<>
{tab === 'creatures' && <Creatures q={q} facet={facet} />}
{tab === 'champions' && <Champions facet={facet} />}
{tab === 'places' && <Places q={q} facet={facet} />}
</>
)}
</div>
</PublicLayout>
)
}

View File

@@ -0,0 +1,201 @@
import { useMemo, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
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 { api } from '../../api/client.js'
// One creature: where it spawns, and what spawns alongside it.
//
// `places` is the point of the page — the aggregate that turns 62 raw
// coordinates into "Shrines, Isamu-Jima, Yew". The individual spawners are
// available underneath for the reader who actually wants a coordinate, but they
// are secondary and collapsed by default.
const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : '—')
// Spawn delays are stored in seconds. A raw "1200" tells the reader nothing.
function delay(min, max) {
const fmt = (s) => (s >= 60 ? `${Math.round(s / 60)}m` : `${s}s`)
if (!Number.isFinite(min) || !Number.isFinite(max)) return null
if (min === max) return fmt(min)
return `${fmt(min)}${fmt(max)}`
}
function Panel({ title, right, children }) {
return (
<section className="panel" style={{ padding: 18 }}>
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}>
<h2 className="display" style={{ margin: '0 0 12px', fontSize: '1.02rem', color: 'var(--head)' }}>
{title}
</h2>
{right}
</div>
{children}
</section>
)
}
function Places({ places }) {
if (places.length === 0) {
return <p className="sans dim" style={{ margin: 0 }}>No placed spawners.</p>
}
return (
<div>
{places.map((place) => (
<div
key={`${place.facet}:${place.label}`}
className="sans"
style={{
display: 'flex',
alignItems: 'baseline',
justifyContent: 'space-between',
gap: 12,
padding: '6px 0',
borderBottom: '1px solid var(--line)',
fontSize: '0.86rem',
}}
>
<span style={{ minWidth: 0, color: 'var(--head)' }}>{place.label}</span>
<span className="dim" style={{ flex: 'none' }}>
{place.facet} · {num(place.spawners)} spawner{place.spawners === 1 ? '' : 's'} · up to{' '}
{num(place.maxAlive)} at once
</span>
</div>
))}
</div>
)
}
function Spawners({ spawners, truncated }) {
const [open, setOpen] = useState(false)
if (spawners.length === 0) return null
return (
<Panel
title="Individual spawners"
right={
<button
type="button"
className="sans"
onClick={() => setOpen((v) => !v)}
style={{ background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer', fontSize: '0.78rem' }}
>
{open ? 'Hide' : `Show ${num(spawners.length)}`}
</button>
}
>
{open && (
<div style={{ overflowX: 'auto' }}>
<table className="sans" style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8rem' }}>
<thead>
<tr style={{ textAlign: 'left', color: 'var(--muted)' }}>
<th style={{ padding: '4px 8px 8px 0' }}>Place</th>
<th style={{ padding: '4px 8px 8px 0' }}>Facet</th>
<th style={{ padding: '4px 8px 8px 0' }}>Coords</th>
<th style={{ padding: '4px 8px 8px 0' }}>Max</th>
<th style={{ padding: '4px 0 8px 0' }}>Respawn</th>
</tr>
</thead>
<tbody>
{spawners.map((s) => (
<tr key={s.id} style={{ borderTop: '1px solid var(--line)' }}>
<td style={{ padding: '6px 8px 6px 0', color: 'var(--head)' }}>{s.label}</td>
<td style={{ padding: '6px 8px 6px 0' }} className="dim">{s.facet}</td>
<td style={{ padding: '6px 8px 6px 0' }} className="dim">{s.x}, {s.y}</td>
<td style={{ padding: '6px 8px 6px 0' }} className="dim">{num(s.maxCount)}</td>
<td style={{ padding: '6px 0' }} className="dim">{delay(s.minDelay, s.maxDelay) || '—'}</td>
</tr>
))}
</tbody>
</table>
{truncated && (
<p className="sans dim" style={{ fontSize: '0.74rem', margin: '10px 0 0' }}>
Only the largest spawners are listed.
</p>
)}
</div>
)}
</Panel>
)
}
export default function AtlasCreature() {
const { slug } = useParams()
const { loading, error, data } = useAsync(() => api.atlas.creature(slug), [slug])
// A 404 here means "no such creature in this atlas", which is a real answer
// and not a failure — a visitor following a stale link deserves to be told
// that plainly rather than shown a generic error box.
const missing = error?.status === 404 || error?.message === 'Not Found'
const facets = useMemo(
() => Object.entries(data?.facets || {}).sort((a, b) => b[1] - a[1]),
[data],
)
return (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<p className="sans" style={{ marginBottom: 8 }}>
<Link to="/site/atlas" style={{ color: 'var(--accent)', fontSize: '0.78rem' }}>
Spawn atlas
</Link>
</p>
{loading && <Loading />}
{error && !missing && <ErrorState message="Could not load that creature right now." />}
{missing && <EmptyState>Nothing by that name spawns on this shard.</EmptyState>}
{!loading && !error && data && (
<>
<PageHeader
eyebrow="Bestiary"
title={data.name}
lead={`Up to ${num(data.total)} alive at once across ${num(data.points)} spawner${data.points === 1 ? '' : 's'}.`}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<Panel
title="Where it spawns"
right={
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
{facets.map(([facet, n]) => `${facet} (${n})`).join(' · ')}
</span>
}
>
<Places places={data.places || []} />
</Panel>
<Spawners spawners={data.spawners || []} truncated={!!data.spawnersTruncated} />
{data.alsoHere?.length > 0 && (
<Panel title="Shares a spawner with">
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{data.alsoHere.map((other) => (
<Link
key={other.slug}
to={`/site/atlas/${encodeURIComponent(other.slug)}`}
className="sans"
style={{
fontSize: '0.78rem',
padding: '4px 11px',
borderRadius: 999,
border: '1px solid var(--line)',
color: 'var(--muted)',
textDecoration: 'none',
}}
>
{other.name} <span className="dim">×{num(other.shared)}</span>
</Link>
))}
</div>
</Panel>
)}
</div>
</>
)}
</div>
</PublicLayout>
)
}

View File

@@ -91,6 +91,11 @@ function ChampDetail({ s }) {
) )
} }
// champion // 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 ( return (
<> <>
<div className="sans" style={line}> <div className="sans" style={line}>
@@ -98,13 +103,7 @@ function ChampDetail({ s }) {
Level {s.level ?? 0} Level {s.level ?? 0}
{s.bossUp && s.boss ? `${s.boss}` : ''} {s.bossUp && s.boss ? `${s.boss}` : ''}
</span> </span>
<span> <span>{progress}</span>
{s.status === 'cooldown'
? until(s.restartAt) || 'restarting'
: s.status === 'active'
? `${Number(s.kills || 0).toLocaleString()} / ${Number(s.maxKills || 0).toLocaleString()} kills`
: ''}
</span>
</div> </div>
{s.status === 'active' && ( {s.status === 'active' && (
<div style={{ marginTop: 6 }}><Meter value={s.kills} max={s.maxKills} /></div> <div style={{ marginTop: 6 }}><Meter value={s.kills} max={s.maxKills} /></div>

View File

@@ -79,8 +79,8 @@ function TermHistory({ city }) {
)} )}
{data && data.length > 0 && ( {data && data.length > 0 && (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 5 }}> <ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 5 }}>
{data.map((t, i) => ( {data.map((t) => (
<li key={i} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 10, fontSize: '0.8rem', color: 'var(--ink)' }}> <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' }}> <span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{t.governor?.name || 'Vacant'} {t.governor?.name || 'Vacant'}
</span> </span>
@@ -100,6 +100,7 @@ function TermHistory({ city }) {
function CityCard({ c }) { function CityCard({ c }) {
const phase = PHASE[c.electionPhase] || null const phase = PHASE[c.electionPhase] || null
const gov = c.governor const gov = c.governor
const candidatePlural = c.candidates === 1 ? '' : 's'
return ( return (
<div className="panel" style={{ padding: 18 }}> <div className="panel" style={{ padding: 18 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
@@ -127,7 +128,7 @@ function CityCard({ c }) {
{c.electionPhase && c.electionPhase !== 'none' && ( {c.electionPhase && c.electionPhase !== 'none' && (
<div className="sans dim" style={{ marginTop: 10, fontSize: '0.78rem' }}> <div className="sans dim" style={{ marginTop: 10, fontSize: '0.78rem' }}>
{c.candidates ? `${c.candidates} candidate${c.candidates === 1 ? '' : 's'}` : 'No candidates yet'} {c.candidates ? `${c.candidates} candidate${candidatePlural}` : 'No candidates yet'}
{c.autoPickAt && until(c.autoPickAt) ? ` · resolves ${until(c.autoPickAt)}` : ''} {c.autoPickAt && until(c.autoPickAt) ? ` · resolves ${until(c.autoPickAt)}` : ''}
</div> </div>
)} )}

View File

@@ -0,0 +1,240 @@
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'
import { useSite } from '../../contexts/SiteContext.jsx'
// Points / loyalty leaderboards (Protocol 3.0 §7). The shard carries ~25 separate
// point currencies — Queen's Loyalty, Void Pool, Clean Up Britannia, the nine city
// loyalties, the Doom/Khaldun/Kotl treasure systems — every one of them a standing
// players build over months, and none of them visible anywhere but an in-game gump
// until now.
//
// Loaded from /public/shard/points, then kept current from the live feed. Unlike
// the ruleset (one frame = the whole thing), a points.board frame describes ONE
// system, so live frames are merged over the fetched set by system key rather than
// replacing it.
const POINTS_KINDS = new Set(['points.board'])
// A board's display name may arrive as a literal (`nameString`), a cliloc id
// (`nameNumber`), or both — Name is a ServUO TextDefinition. We have no cliloc
// table on the site, so a cliloc-only board falls back to humanising its own
// PointsType key, which is already close to a display name ("CleanUpBritannia" →
// "Clean Up Britannia"). Better than showing a bare number.
const humanise = (key) =>
String(key || '')
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/^./, (c) => c.toUpperCase())
const boardTitle = (b) => b.nameString || humanise(b.system)
const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : '—')
// Merge live frames over the fetched boards. Newest frame per system wins; a
// system that has never appeared in either is simply absent.
function mergeBoards(fetched, events) {
const bySystem = new Map()
for (const b of Array.isArray(fetched) ? fetched : []) {
if (b && b.system) bySystem.set(b.system, b)
}
// Events arrive newest-first, so walk backwards and let the newest land last.
for (let i = events.length - 1; i >= 0; i--) {
const ev = events[i]
if (ev && ev.system) bySystem.set(ev.system, ev)
}
return [...bySystem.values()].sort((a, b) => boardTitle(a).localeCompare(boardTitle(b)))
}
function Medal({ rank }) {
// Gold / silver / bronze for the podium, plain for the rest.
const tone = rank === 1 ? '#c9a24b' : rank === 2 ? '#b6bcc6' : rank === 3 ? '#b3805a' : 'var(--muted)'
return (
<span
className="display"
style={{
flex: 'none', width: 26, textAlign: 'right', color: tone,
fontSize: rank <= 3 ? '1rem' : '0.86rem',
}}
>
{rank}
</span>
)
}
// One ranked player. `name` is absent rather than empty when an admin has gated
// the leaderboards `name` field above this viewer's rung — the row still renders,
// because the standing itself is the point.
function Entry({ entry, best }) {
const pct = best > 0 ? Math.max(2, Math.round((entry.points / best) * 100)) : 0
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 0' }}>
<Medal rank={entry.rank} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 10 }}>
<span
className="sans"
style={{
color: entry.name ? 'var(--ink)' : 'var(--muted)',
fontSize: '0.86rem', fontStyle: entry.name ? 'normal' : 'italic',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}
>
{entry.name || 'Name hidden'}
</span>
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.82rem', flex: 'none' }}>
{num(entry.points)}
</span>
</div>
<div style={{ height: 4, borderRadius: 999, background: 'var(--line)', overflow: 'hidden', marginTop: 3 }}>
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
</div>
</div>
</div>
)
}
function Board({ board }) {
const { siteTitle } = useSite()
const top = Array.isArray(board.top) ? board.top : []
// Bars are relative to the board leader, not to maxPoints: most systems have no
// cap (maxPoints 0), and where there is one the leader is often nowhere near it,
// which would render every bar as a stub.
const best = top.reduce((m, e) => Math.max(m, e.points || 0), 0)
return (
<section className="panel" style={{ padding: 18, display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 10 }}>
<h2 className="display" style={{ margin: 0, fontSize: '1.02rem', color: 'var(--head)' }}>
{boardTitle(board)}
</h2>
{Number.isFinite(board.players) && (
<span className="sans dim" style={{ fontSize: '0.72rem', flex: 'none' }}>
{num(board.players)} ranked
</span>
)}
</div>
{top.length === 0 ? (
// A board nobody has scored on still gets a row, so the page reads as a set
// of standings waiting to be filled rather than a stack of blanks. It is
// deliberately NOT shaped like an Entry — no medal, no bar, an em dash where
// a score goes — because a placeholder that looked like a real standing would
// be a fabricated one. The first real entry replaces it.
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 10, padding: '6px 0' }}>
<span
className="sans"
style={{
color: 'var(--muted)', fontSize: '0.86rem',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}
>
{siteTitle}
</span>
<span className="sans dim" style={{ fontSize: '0.82rem', flex: 'none' }}>&mdash;</span>
</div>
<p className="sans dim" style={{ margin: 0, fontSize: '0.78rem' }}>
Nobody has earned points here yet.
</p>
</div>
) : (
<div>
{top.map((entry) => (
<Entry key={`${board.system}-${entry.rank}-${entry.serial}`} entry={entry} best={best} />
))}
</div>
)}
{Number.isFinite(board.maxPoints) && board.maxPoints > 0 && (
<span className="sans dim" style={{ fontSize: '0.72rem' }}>
Maximum {num(board.maxPoints)} points
</span>
)}
</section>
)
}
export default function Leaderboards() {
const { loading, error, data } = useAsync(() => api.shard.points())
// Buffer generously: a single sweep can emit a frame for every system at once,
// and a board dropped from the buffer would silently revert to its fetched copy.
const { events, connected } = useShardFeed({ filter: POINTS_KINDS, max: 60 })
const [query, setQuery] = useState('')
const boards = useMemo(() => mergeBoards(data, events), [data, events])
const shown = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) return boards
// Match the board name, the raw system key, or any ranked player on it — the
// last is what makes the filter useful ("where do I appear?").
return boards.filter(
(b) =>
boardTitle(b).toLowerCase().includes(q) ||
String(b.system).toLowerCase().includes(q) ||
(b.top || []).some((e) => e.name && e.name.toLowerCase().includes(q)),
)
}, [boards, query])
return (
<PublicLayout section="website">
<div className="shell page-body">
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
<PageHeader
eyebrow="Live"
title="Leaderboards"
lead="Loyalty and points standings, straight from the shard — every currency the server tracks, updated as players climb."
/>
<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 leaderboards right now." />}
{!loading && !error && boards.length === 0 && (
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
<p className="sans dim" style={{ margin: 0 }}>
The shard has not published any leaderboards yet.
</p>
</section>
)}
{!loading && !error && boards.length > 0 && (
<>
<input
className="input"
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Filter by board or player name…"
aria-label="Filter leaderboards"
style={{ maxWidth: 340, marginBottom: 14 }}
/>
{shown.length === 0 ? (
<p className="sans dim">No board or ranked player matches {query}.</p>
) : (
<div className="grid-2" style={{ gap: 12, alignItems: 'start' }}>
{shown.map((board) => (
<Board key={board.system} board={board} />
))}
</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

@@ -0,0 +1,325 @@
import { useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
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 { api } from '../../api/client.js'
// ── The player-vendor marketplace ───────────────────────────────────────────
//
// What every player vendor on the shard is selling, for how much, and where it
// is standing — the same index the in-game Vendor Search gump reads, honouring
// the same per-vendor opt-out, reachable without logging in to the game.
//
// Three things this page must be honest about, all of them consequences of how
// the data is gathered (docs/link/v3.md §8):
//
// • **The prices are not live.** The shard sweeps vendors round-robin, so a
// shop can be a full cycle behind. The banner says how far, from `staleAt`.
// A page that implied live prices would send people across the world to a
// vendor whose item sold twenty minutes ago.
// • **A shop can be truncated.** A commodity reseller with thousands of stacks
// publishes only the first N, and saying so beats presenting a partial shop
// as complete.
// • **An item may have no name.** On a shard whose operator has not converted
// a cliloc table, `displayName` is null and the honest render is the item id
// — not an invented name.
//
// There is deliberately no live feed here. The market feature's SSE stream ships
// disabled: a firehose of whole vendor inventories would be the site's single
// biggest bandwidth consumer, and nothing on this page needs it.
const PAGE = 50
const num = (v) => (Number.isFinite(Number(v)) ? Number(v).toLocaleString() : '—')
const SORTS = [
{ key: 'price_asc', label: 'Cheapest' },
{ key: 'price_desc', label: 'Priciest' },
{ key: 'recent', label: 'Recently seen' },
]
// How old the index may be, in words. `staleAt` is the OLDEST vendor row, so
// this is a worst case rather than an average — which is the number worth
// showing, because the one stale shop is the one that wastes a trip.
function staleness(staleAt) {
if (!staleAt) return null
const ms = Date.now() - new Date(staleAt).getTime()
if (!Number.isFinite(ms) || ms < 0) return null
const mins = Math.round(ms / 60000)
if (mins < 1) return 'just now'
if (mins < 60) return `${mins} minute${mins === 1 ? '' : 's'} ago`
const hours = Math.round(mins / 60)
if (hours < 48) return `${hours} hour${hours === 1 ? '' : 's'} ago`
return `${Math.round(hours / 24)} days ago`
}
// The item's name, or an honest statement that we do not have one. Never a
// fabricated label — "Item 3922" would be indistinguishable from a real name.
const itemLabel = (l) => l.displayName || l.name || `id ${l.itemId}`
function Chip({ active, onClick, children }) {
return (
<button
type="button"
onClick={onClick}
className="sans"
style={{
fontSize: '0.78rem',
padding: '5px 12px',
borderRadius: 999,
cursor: 'pointer',
color: active ? 'var(--bg-deep)' : 'var(--muted)',
background: active ? 'var(--accent)' : 'transparent',
border: `1px solid ${active ? 'var(--accent)' : 'var(--line)'}`,
}}
>
{children}
</button>
)
}
function ListingRow({ listing }) {
const v = listing.vendor || {}
// `location` is one field the admin can gate away wholesale, so everything
// that reads from it has to tolerate its absence rather than assuming a map.
const loc = v.location || null
const where = loc ? [loc.region, loc.map].filter(Boolean).join(', ') : null
return (
<div className="panel" style={{ padding: '13px 15px', display: 'flex', gap: 14, alignItems: 'center' }}>
<div style={{ minWidth: 0, flex: 1 }}>
<div
className="display"
style={{ fontSize: '0.98rem', color: 'var(--head)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
>
{listing.amount > 1 ? `${num(listing.amount)} × ` : ''}
{itemLabel(listing)}
</div>
<div className="sans dim" style={{ fontSize: '0.74rem', marginTop: 3 }}>
{v.serial ? (
<Link to={`/site/market/vendors/${encodeURIComponent(v.serial)}`} style={{ color: 'inherit' }}>
{v.shopName || 'an unnamed shop'}
</Link>
) : (
v.shopName || 'an unnamed shop'
)}
{v.ownerName ? ` · ${v.ownerName}` : ''}
{where ? ` · ${where}` : ''}
{/* Priced by the container it sits in, exactly as the in-game search
reports it — the price buys the whole container, not this item. */}
{listing.child ? ' · sold with its container' : ''}
</div>
</div>
<div className="sans" style={{ flex: 'none', textAlign: 'right' }}>
<div style={{ color: 'var(--head)', fontSize: '0.92rem' }}>{num(listing.price)}</div>
<div className="dim" style={{ fontSize: '0.68rem', letterSpacing: '0.05em' }}>gold</div>
</div>
</div>
)
}
export default function Market() {
const [input, setInput] = useState('')
const [q, setQ] = useState('')
const [map, setMap] = useState('')
const [region, setRegion] = useState('')
const [sort, setSort] = useState('price_asc')
const [minPrice, setMinPrice] = useState('')
const [maxPrice, setMaxPrice] = useState('')
// Applied prices are separate from the typed ones so the search fires when the
// user is done, not on every digit of "250000".
const [prices, setPrices] = useState({ min: '', max: '' })
const [state, setState] = useState({ loading: true, error: null, listings: [], total: 0, staleAt: null })
const [more, setMore] = useState(false)
const meta = useAsync(() => api.shard.marketMeta())
// Debounced: typing "vanquishing" should be one request, not eleven — and the
// endpoint is rate-limited, so an undebounced box would 429 a fast typist.
useEffect(() => {
const timer = setTimeout(() => setQ(input.trim()), 300)
return () => clearTimeout(timer)
}, [input])
useEffect(() => {
const timer = setTimeout(() => setPrices({ min: minPrice, max: maxPrice }), 500)
return () => clearTimeout(timer)
}, [minPrice, maxPrice])
const load = useCallback(
(offset) =>
api.shard.market({
q,
map,
region,
sort,
minPrice: prices.min,
maxPrice: prices.max,
limit: PAGE,
offset,
}),
[q, map, region, sort, prices],
)
useEffect(() => {
let alive = true
setState({ loading: true, error: null, listings: [], total: 0, staleAt: null })
load(0)
.then((page) => {
if (!alive) return
setState({
loading: false,
error: null,
listings: page.listings || [],
total: page.total || 0,
staleAt: page.staleAt || null,
})
})
.catch((error) => alive && setState({ loading: false, error, listings: [], total: 0, staleAt: null }))
return () => {
alive = false
}
}, [load])
const loadMore = async () => {
setMore(true)
try {
const page = await load(state.listings.length)
setState((s) => ({
...s,
listings: [...s.listings, ...(page.listings || [])],
total: page.total ?? s.total,
staleAt: page.staleAt ?? s.staleAt,
}))
} catch {
// A failed "load more" leaves what is on screen alone; the button stays
// available to retry.
} finally {
setMore(false)
}
}
const maps = meta.data?.maps || []
const regions = meta.data?.regions || []
const age = staleness(state.staleAt)
return (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<PageHeader
eyebrow="Marketplace"
title="Player vendors"
lead="Every shop on the shard, searchable from here — the same index the in-game vendor search reads, and it honours the same per-vendor opt-out."
/>
{/* Not decoration. The sweep is round-robin, so the index is inherently
up to one full cycle old and the page has to say so. */}
{age && (
<p className="sans dim" style={{ fontSize: '0.76rem', margin: '-12px 0 18px' }}>
Prices last refreshed {age}
{meta.data?.vendors ? ` · ${num(meta.data.vendors)} shops` : ''}
{meta.data?.items ? ` · ${num(meta.data.items)} listings` : ''}
</p>
)}
<input
className="input"
type="search"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Search listings…"
style={{ width: '100%', marginBottom: 10 }}
/>
<div style={{ display: 'flex', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
<input
className="input"
type="number"
min="0"
value={minPrice}
onChange={(e) => setMinPrice(e.target.value)}
placeholder="Min price"
style={{ maxWidth: 140 }}
/>
<input
className="input"
type="number"
min="0"
value={maxPrice}
onChange={(e) => setMaxPrice(e.target.value)}
placeholder="Max price"
style={{ maxWidth: 140 }}
/>
</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 10 }}>
{SORTS.map((s) => (
<Chip key={s.key} active={sort === s.key} onClick={() => setSort(s.key)}>
{s.label}
</Chip>
))}
</div>
{/* Facet and region names come from the shard's own data, never a list in
this file — a shard running custom maps gets its own names here with
no code change (docs/link/v3.md §6.1 R2). */}
{maps.length > 0 && (
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 10 }}>
<Chip active={map === ''} onClick={() => setMap('')}>All facets</Chip>
{maps.map((m) => (
<Chip key={m} active={map === m} onClick={() => setMap(m)}>{m}</Chip>
))}
</div>
)}
{regions.length > 0 && (
<select
className="input"
value={region}
onChange={(e) => setRegion(e.target.value)}
style={{ width: '100%', marginBottom: 18 }}
>
<option value="">Anywhere</option>
{regions.map((r) => (
<option key={r} value={r}>{r}</option>
))}
</select>
)}
{state.loading && <Loading />}
{state.error && <ErrorState message="Could not load the marketplace right now." />}
{!state.loading && !state.error && state.listings.length === 0 && (
<EmptyState>
{meta.data?.vendors
? 'Nothing on the shard matches that.'
: 'No player vendors have been indexed yet.'}
</EmptyState>
)}
{!state.loading && !state.error && state.listings.length > 0 && (
<>
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 12px' }}>
Showing {num(state.listings.length)} of {num(state.total)}
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{state.listings.map((l) => (
<ListingRow key={`${l.vendor?.serial}:${l.serial}`} listing={l} />
))}
</div>
{state.listings.length < state.total && (
<div style={{ textAlign: 'center', marginTop: 16 }}>
<button type="button" className="btn" onClick={loadMore} disabled={more}>
{more ? 'Loading…' : 'Load more'}
</button>
</div>
)}
</>
)}
</div>
</PublicLayout>
)
}

View File

@@ -0,0 +1,102 @@
import { Link, useParams } from 'react-router-dom'
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 { api } from '../../api/client.js'
// One player vendor: where to find it and everything it is selling.
//
// The page a search result points at. Two states it has to render honestly and
// which the search list cannot (docs/link/v3.md §8):
//
// • `truncated` — the shop holds more than the shard publishes per frame. A
// commodity reseller with thousands of stacks is a real thing, and showing
// 250 of 3,104 as if it were the whole shop would be a lie about the shard.
// • a gated `location` — an admin may put vendor whereabouts behind a rung, in
// which case there is nothing to render and the page says so rather than
// showing an empty coordinate.
const num = (v) => (Number.isFinite(Number(v)) ? Number(v).toLocaleString() : '—')
const itemLabel = (i) => i.displayName || i.name || `id ${i.itemId}`
export default function MarketVendor() {
const { serial } = useParams()
const { loading, error, data } = useAsync(() => api.shard.marketVendor(serial), [serial])
if (loading) {
return (
<PublicLayout section="website">
<div className="shell-narrow page-body"><Loading /></div>
</PublicLayout>
)
}
if (error || !data) {
return (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<ErrorState message="That shop is not in the index — it may have been dismissed or hidden." />
<p style={{ marginTop: 16 }}>
<Link to="/site/market" className="sans"> Back to the marketplace</Link>
</p>
</div>
</PublicLayout>
)
}
const loc = data.location || null
const items = data.items || []
return (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<PageHeader
eyebrow={data.ownerName ? `Run by ${data.ownerName}` : 'Player vendor'}
title={data.shopName || 'An unnamed shop'}
lead={
loc
? [loc.house, loc.region, loc.map].filter(Boolean).join(' · ') +
(Number.isFinite(loc.x) ? `${loc.x}, ${loc.y}` : '')
: 'This shard does not publish vendor locations.'
}
/>
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '-12px 0 18px' }}>
{data.truncated
? `Showing ${num(data.count)} of ${num(data.total)} listings — this shop holds more than the shard publishes.`
: `${num(data.total)} listing${data.total === 1 ? '' : 's'}`}
{data.updatedAt ? ` · last seen ${new Date(data.updatedAt).toLocaleString()}` : ''}
</p>
{items.length === 0 ? (
<EmptyState>This shop has nothing priced for sale.</EmptyState>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{items.map((i) => (
<div
key={i.serial}
className="panel"
style={{ padding: '10px 14px', display: 'flex', gap: 12, alignItems: 'baseline' }}
>
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--head)', fontSize: '0.88rem' }}>
{i.amount > 1 ? `${num(i.amount)} × ` : ''}
{itemLabel(i)}
{i.child ? <span className="dim"> · sold with its container</span> : null}
</span>
<span className="sans" style={{ flex: 'none', color: 'var(--head)', fontSize: '0.88rem' }}>
{num(i.price)}
</span>
</div>
))}
</div>
)}
<p style={{ marginTop: 20 }}>
<Link to="/site/market" className="sans"> Back to the marketplace</Link>
</p>
</div>
</PublicLayout>
)
}

View File

@@ -0,0 +1,341 @@
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 shard ruleset. Loaded from /public/shard/ruleset, replaced wholesale by any
// world.ruleset frame on the live feed (the shard re-emits the entire ruleset, so
// there is nothing to merge — latest wins).
//
// Everything on this page is published BY THE SHARD from its own Config/*.cfg, so
// it cannot drift the way a hand-written rules page does. That is the whole point
// of the feature, and the page says so.
const RULESET_KINDS = new Set(['world.ruleset'])
// Skill and stat caps arrive in tenths, the way ServUO stores them: 1000 is 100.0
// skill. Showing the raw number would be actively misleading.
const tenths = (v) => (Number.isFinite(v) ? (v / 10).toFixed(1) : null)
const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : null)
const pct = (v) => (Number.isFinite(v) ? `${v}%` : null)
// The systems block is a flat bag of booleans; these are their display names, and
// the order here is the order they render. A key the shard sends that we don't
// know about still renders, humanised, rather than being silently dropped — a new
// plugin must not go invisible against an older client.
const SYSTEM_LABELS = {
cityLoyalty: 'City Loyalty (governors)',
vvv: 'Vice vs Virtue',
factions: 'Factions',
siege: 'Siege ruleset',
chat: 'In-game chat',
store: 'Ultima Store',
dailyRares: 'Daily rares',
honesty: 'Honesty virtue',
shadowguard: 'Shadowguard',
treasureMaps: 'Treasure maps',
vetRewards: 'Veteran rewards',
testCenter: 'Test Center',
}
const humanise = (key) =>
key.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase())
function Panel({ title, children }) {
return (
<section className="panel" style={{ padding: 18 }}>
<h2
className="display"
style={{ margin: '0 0 12px', fontSize: '1.02rem', color: 'var(--head)' }}
>
{title}
</h2>
{children}
</section>
)
}
// A label/value row. Rows whose value is null are dropped by the caller, so a
// block never renders a dangling label for something the shard didn't publish.
function Row({ label, value }) {
return (
<div
className="sans"
style={{
display: 'flex',
alignItems: 'baseline',
justifyContent: 'space-between',
gap: 12,
padding: '5px 0',
borderBottom: '1px solid var(--line)',
fontSize: '0.86rem',
}}
>
<span className="dim" style={{ minWidth: 0 }}>{label}</span>
<strong style={{ flex: 'none', color: 'var(--head)' }}>{value}</strong>
</div>
)
}
function Rows({ items }) {
const rows = items.filter(([, value]) => value !== null && value !== undefined)
if (rows.length === 0) return null
return (
<div>
{rows.map(([label, value]) => (
<Row key={label} label={label} value={value} />
))}
</div>
)
}
function SystemPill({ label, on }) {
const color = on ? '#8fdcae' : 'var(--muted)'
return (
<span
className="sans"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 7,
fontSize: '0.8rem',
padding: '5px 11px',
borderRadius: 999,
color,
background: on ? 'rgba(95,185,138,0.12)' : 'rgba(140,150,165,0.1)',
border: `1px solid ${on ? 'rgba(95,185,138,0.4)' : 'var(--line)'}`,
}}
>
<span
aria-hidden="true"
style={{ width: 7, height: 7, borderRadius: '50%', background: color, flex: 'none' }}
/>
{label}
</span>
)
}
function Systems({ systems }) {
// Known keys first in their declared order, then anything the shard added that
// this build doesn't know about.
const known = Object.keys(SYSTEM_LABELS).filter((k) => k in systems)
const extra = Object.keys(systems).filter((k) => !(k in SYSTEM_LABELS))
const keys = [...known, ...extra]
if (keys.length === 0) return null
return (
<Panel title="Systems">
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{keys.map((k) => (
<SystemPill key={k} label={SYSTEM_LABELS[k] || humanise(k)} on={!!systems[k]} />
))}
</div>
</Panel>
)
}
function Caps({ caps }) {
return (
<Panel title="Skill & stat caps">
<Rows
items={[
['Individual skill cap', tenths(caps.skill)],
['Total skill cap', tenths(caps.totalSkill)],
['Total stat cap', num(caps.stat)],
['Strength cap', num(caps.str)],
['Dexterity cap', num(caps.dex)],
['Intelligence cap', num(caps.int)],
['Strength max', num(caps.strMax)],
['Dexterity max', num(caps.dexMax)],
['Intelligence max', num(caps.intMax)],
]}
/>
</Panel>
)
}
function AccountsAndHousing({ accounts, housing, vetRewards }) {
const items = []
if (accounts) {
items.push(['Accounts per IP', num(accounts.perIp)])
items.push(['Character slots', num(accounts.charSlots)])
items.push([
'In-game account creation',
accounts.autoCreate === undefined ? null : accounts.autoCreate ? 'Enabled' : 'Website only',
])
}
if (housing) items.push(['Houses per account', num(housing.accountHouseLimit)])
if (vetRewards?.enabled) {
items.push(['Veteran reward interval', vetRewards.rewardIntervalDays
? `${vetRewards.rewardIntervalDays} days`
: null])
}
if (items.length === 0) return null
return (
<Panel title="Accounts & housing">
<Rows items={items} />
</Panel>
)
}
function Champions({ champions }) {
const t = champions.rankThresholds
return (
<Panel title="Champion spawns">
<Rows
items={[
['Power scrolls per spawn', num(champions.powerScrolls)],
['Stat scrolls per spawn', num(champions.statScrolls)],
['Scroll drop chance', pct(champions.scrollChance)],
['Transcendence chance', pct(champions.transcendenceChance)],
[
'Red skulls per rank',
Array.isArray(t) && t.length > 0 ? t.join(' · ') : null,
],
]}
/>
</Panel>
)
}
function Felucca({ loot }) {
return (
<Panel title="Felucca bonuses">
<Rows
items={[
['Luck bonus', num(loot.feluccaLuckBonus)],
['Loot budget bonus', num(loot.feluccaBudgetBonus)],
['Max item properties', num(loot.feluccaMaxProps)],
]}
/>
</Panel>
)
}
function Vendors({ vendors }) {
return (
<Panel title="Vendors">
<Rows
items={[
['Restock delay', vendors.restockDelayMinutes
? `${vendors.restockDelayMinutes} min`
: null],
['Max items sold at once', num(vendors.maxSell)],
['Economy stock amount', num(vendors.economyStockAmount)],
]}
/>
</Panel>
)
}
function Pvp({ vvv }) {
return (
<Panel title="Vice vs Virtue">
<Rows
items={[
['Starting silver', num(vvv.startSilver)],
['Enhanced rules', vvv.enhancedRules === undefined
? null
: vvv.enhancedRules ? 'On' : 'Off'],
]}
/>
</Panel>
)
}
function Schedule({ schedule }) {
const items = []
if (schedule.autoSaveEnabled && schedule.autoSaveFrequencyMinutes) {
items.push(['World save', `every ${schedule.autoSaveFrequencyMinutes} min`])
} else if (schedule.autoSaveEnabled === false) {
items.push(['World save', 'Disabled'])
}
if (schedule.autoRestartEnabled) {
const h = String(schedule.autoRestartHour ?? 0).padStart(2, '0')
const m = String(schedule.autoRestartMinute ?? 0).padStart(2, '0')
items.push(['Automatic restart', `${h}:${m} server time`])
if (schedule.autoRestartFrequencyHours) {
items.push(['Restart interval', `every ${schedule.autoRestartFrequencyHours}h`])
}
}
if (items.length === 0) return null
return (
<Panel title="Save & restart schedule">
<Rows items={items} />
</Panel>
)
}
export default function Rules() {
const { loading, error, data } = useAsync(() => api.shard.ruleset())
const { events, connected } = useShardFeed({ filter: RULESET_KINDS, max: 4 })
// The newest world.ruleset on the feed wins outright over the fetched copy —
// the frame is a complete ruleset, not a delta.
const ruleset = useMemo(() => events[0] || data || null, [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="Shard ruleset"
lead="Published by the server itself, straight from its configuration — so it cannot drift from how the shard actually plays."
/>
<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 shard ruleset right now." />}
{!loading && !error && !ruleset && (
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
<p className="sans dim" style={{ margin: 0 }}>
The shard has not published its ruleset yet.
</p>
</section>
)}
{!loading && !error && ruleset && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<Panel title="Shard">
<Rows
items={[
['Name', ruleset.shard || null],
['Expansion', ruleset.expansion || null],
['Connect', ruleset.connect || null],
]}
/>
</Panel>
{ruleset.systems && <Systems systems={ruleset.systems} />}
{ruleset.caps && <Caps caps={ruleset.caps} />}
<AccountsAndHousing
accounts={ruleset.accounts}
housing={ruleset.housing}
vetRewards={ruleset.vetRewards}
/>
{ruleset.champions && <Champions champions={ruleset.champions} />}
{ruleset.loot && <Felucca loot={ruleset.loot} />}
{ruleset.vendors && <Vendors vendors={ruleset.vendors} />}
{ruleset.vvv?.enabled && <Pvp vvv={ruleset.vvv} />}
{ruleset.schedule && <Schedule schedule={ruleset.schedule} />}
</div>
)}
</div>
</PublicLayout>
)
}

View File

@@ -10,6 +10,14 @@ import { api } from '../../api/client.js'
import PlayersOnline from '../../components/PlayersOnline.jsx' import PlayersOnline from '../../components/PlayersOnline.jsx'
import { useAuth } from '../../contexts/AuthContext.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 ─────────────────────────────────────────────────── // ── Gold-supply sparkline ───────────────────────────────────────────────────
function Sparkline({ series }) { function Sparkline({ series }) {
if (!series || series.length < 2) return null if (!series || series.length < 2) return null
@@ -75,44 +83,7 @@ export default function Shard() {
{!loading && !error && data && ( {!loading && !error && data && (
<> <>
{/* Connection banner */} <ConnectionBanner online={online} status={status} />
<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' }}>
{online
? 'The gate to Britannia stands open.'
: status?.enabled
? 'The link to the game world is down — checking back automatically.'
: 'Live shard data is not configured yet.'}
</span>
</div>
</section>
{/* Stat tiles */} {/* Stat tiles */}
<section className="grid-2" style={{ gap: 14, marginBottom: 24 }}> <section className="grid-2" style={{ gap: 14, marginBottom: 24 }}>
@@ -125,31 +96,7 @@ export default function Shard() {
<PlayersOnline /> <PlayersOnline />
</div> </div>
{/* Staff online — linked staff accounts only; location is admin/mod-only */} <StaffOnline list={data.online} canSeeLocation={canSeeLocation} />
<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>
{(!data.online || data.online.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 }}>
{data.online.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>
{/* Economy sparkline */} {/* Economy sparkline */}
{data.economy && data.economy.length > 1 && ( {data.economy && data.economy.length > 1 && (
@@ -166,11 +113,14 @@ export default function Shard() {
<FeedList <FeedList
title="Houses in danger (IDOC)" title="Houses in danger (IDOC)"
empty="No houses are collapsing right now." empty="No houses are collapsing right now."
items={data.idoc.map((h) => ({ items={data.idoc.map((h) => {
id: h.serial, const region = h.region ? `${h.region}` : ''
text: `${h.name || 'A house'}${h.region ? `${h.region}` : ''}`, return {
when: h.updatedAt, id: h.serial,
}))} text: `${h.name || 'A house'}${region}`,
when: h.updatedAt,
}
})}
/> />
</div> </div>
@@ -212,6 +162,75 @@ export default function Shard() {
) )
} }
// 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 }) { function FeedList({ title, items, empty }) {
return ( return (
<section className="panel" style={{ padding: 20 }}> <section className="panel" style={{ padding: 20 }}>

View File

@@ -68,7 +68,9 @@ export default function Wiki() {
const activeTag = searchParams.get('tag') const activeTag = searchParams.get('tag')
const activeQ = searchParams.get('q') const activeQ = searchParams.get('q')
// Search / tag views fetch a filtered page list; otherwise all pages (grouped here). // Search / tag views fetch a filtered page list; otherwise all pages (grouped here).
const pageOpts = activeQ ? { q: activeQ } : activeTag ? { tag: activeTag } : {} let pageOpts = {}
if (activeQ) pageOpts = { q: activeQ }
else if (activeTag) pageOpts = { tag: activeTag }
const { loading, error, data } = useAsync( const { loading, error, data } = useAsync(
() => () =>
Promise.all([api.wikiCategories(), api.wiki(pageOpts)]).then(([categories, pages]) => ({ Promise.all([api.wikiCategories(), api.wiki(pageOpts)]).then(([categories, pages]) => ({

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

@@ -140,3 +140,44 @@ test('DELETE self-service session revoke encodes the id and uses the DELETE meth
assert.equal(calls[0].opts.method, 'DELETE') assert.equal(calls[0].opts.method, 'DELETE')
assert.match(calls[0].url, /\/auth\/me\/sessions\/a%20b%2Fc$/) assert.match(calls[0].url, /\/auth\/me\/sessions\/a%20b%2Fc$/)
}) })
// ── spawn atlas (Protocol 3.0 Part C) ───────────────────────────────────
// The atlas lives at /public/atlas, NOT under /public/shard: it is static shard
// content parsed from the shard's own files, so it must not look sidecar-backed.
// Asserted here because the split is a design decision, not an accident of
// spelling.
test('atlas reads hit /public/atlas, not /public/shard', async () => {
willReply({ body: { creatures: [] } })
await api.atlas.creatures()
assert.equal(calls[0].url, '/api/v1/public/atlas/creatures')
})
test('atlas.creatures() sends only the filters that are set', async () => {
willReply({ body: { creatures: [] } })
await api.atlas.creatures({ q: 'lizard man', facet: 'Ter Mur', limit: 25 })
const url = new URL(calls[0].url, 'http://x')
assert.equal(url.pathname, '/api/v1/public/atlas/creatures')
assert.equal(url.searchParams.get('q'), 'lizard man')
assert.equal(url.searchParams.get('facet'), 'Ter Mur')
assert.equal(url.searchParams.get('limit'), '25')
assert.equal(url.searchParams.get('offset'), null) // 0 is not sent
})
test('atlas.creature() encodes the slug and carries the facet filter through', async () => {
willReply({ body: {} })
await api.atlas.creature('lizardman/rare', { facet: 'Felucca' })
assert.match(calls[0].url, /\/public\/atlas\/creatures\/lizardman%2Frare\?facet=Felucca$/)
})
test('admin atlas actions use the right methods and bodies', async () => {
willReply({ body: {} })
await api.admin.atlas.import(true)
assert.equal(calls[0].url, '/api/v1/admin/shard/atlas/import')
assert.equal(calls[0].opts.method, 'POST')
assert.equal(calls[0].opts.body, JSON.stringify({ force: true }))
willReply({ body: {} })
await api.admin.atlas.setPath('/srv/servuo')
assert.equal(calls[1].opts.method, 'PUT')
assert.equal(calls[1].opts.body, JSON.stringify({ path: '/srv/servuo' }))
})

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

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

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

@@ -74,9 +74,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

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

58
scripts/dev/README.md Normal file
View File

@@ -0,0 +1,58 @@
# Dev SSO tooling
Local-only helpers for exercising the native **mobile SSO bridge** without a real
OAuth provider. Dev environments have no IdP configured, so `GET /auth/providers`
returns `[]`, the app renders no SSO buttons, and the flow can't be tested. These
scripts stand up a stub IdP, register it, and verify the full bridge headlessly.
> **DEV ONLY.** `stub-idp.js` performs no credential checks and will sign in anyone.
> Never run it against a shared/production database or expose it publicly.
## Files
| File | Role |
|---|---|
| `stub-idp.js` | Dependency-free stub OAuth2/OIDC IdP: `GET /authorize` (account picker), `POST /token`, `GET /userinfo`. |
| `seed-sso-provider.js` | Registers an `auth_providers` row (`devstub`) pointing at the stub and pre-links each principal's `sub` to a dev account (SSO is link-only). Reads DB creds from `server/.env`. |
| `sso-bridge-smoketest.js` | Drives the whole app flow headless: PKCE → `/auth/mobile/sso/start` → stub → website callback → `runicgateway://auth/callback` deep link → `/auth/mobile/sso/exchange`. |
## Usage (host / headless)
```bash
# 1. seed the provider + linked identities (one-time; idempotent)
node scripts/dev/seed-sso-provider.js
# 2. run the stub IdP (leave running)
node scripts/dev/stub-idp.js
# 3. run the website with the callback origin pointed at the API port, so the
# whole flow is same-origin (dev default APP_BASE_URL is the Vite client :5173)
cd server && APP_BASE_URL=http://127.0.0.1:3000 npm start
# 4. verify the bridge (from the website root)
node scripts/dev/sso-bridge-smoketest.js stub-colby # or stub-admin
```
A pass prints the resolved user, an access token, and a present refresh token.
## Usage (Android emulator)
The **authorize** URL is followed by the device browser (Custom Tab); **token** and
**userinfo** are called server-side by the website. On an emulator the host is
`10.0.2.2`, so seed with split URLs:
```bash
STUB_IDP_PUBLIC_URL=http://10.0.2.2:9099 \
STUB_IDP_INTERNAL_URL=http://127.0.0.1:9099 \
node scripts/dev/seed-sso-provider.js
```
Point the app's server at `http://10.0.2.2:3000`, and run the website with
`APP_BASE_URL=http://10.0.2.2:3000` so the IdP callback returns to a device-reachable
origin.
## Principals
`stub-admin` → dev user `admin` (role admin) · `stub-colby` → dev user `colby`
(role player). Keep the `sub` list in sync between `stub-idp.js` and
`seed-sso-provider.js`.

View File

@@ -0,0 +1,105 @@
#!/usr/bin/env node
/*
* Seed a dev SSO provider + pre-linked identities — DEV ONLY.
*
* Registers an `auth_providers` row pointing at the local `stub-idp.js`, so
* `GET /auth/providers` returns a provider and the native mobile SSO flow becomes
* exercisable. Because SSO is link-only (identities are never auto-provisioned),
* it also pre-links each stub principal's `sub` to an existing dev account.
*
* Idempotent: re-running upserts the provider and skips already-linked identities.
*
* Run (loads website/server/.env for DB creds):
* node website/scripts/dev/seed-sso-provider.js
*
* Env overrides:
* STUB_PROVIDER_ID provider slug (default 'devstub')
* STUB_IDP_PUBLIC_URL browser-facing base (default http://127.0.0.1:9099)
* STUB_IDP_INTERNAL_URL server-facing base (default = STUB_IDP_PUBLIC_URL)
*
* The authorize URL is followed by the browser (Custom Tab); token/userinfo are
* called server-side by the website. On an emulator set PUBLIC to the host's
* reachable address (e.g. http://10.0.2.2:9099) and INTERNAL to http://127.0.0.1:9099.
*/
'use strict'
const fs = require('fs')
const path = require('path')
// Load website/server/.env into process.env WITHOUT the dotenv dependency (it
// lives in server/node_modules and wouldn't resolve from this scripts/ location).
// The file is simple KEY=value; that is all we need for the DB credentials.
function loadEnv(envPath) {
if (!fs.existsSync(envPath)) return
for (const line of fs.readFileSync(envPath, 'utf8').split(/\r?\n/)) {
const m = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line)
if (!m) continue
const key = m[1]
let val = m[2].trim()
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
val = val.slice(1, -1)
}
if (process.env[key] === undefined) process.env[key] = val
}
}
loadEnv(path.join(__dirname, '..', '..', 'server', '.env'))
const authProviders = require('../../server/src/model/authProviders/authProviders.model')
const userIdentities = require('../../server/src/model/userIdentities/userIdentities.model')
const { close } = require('../../server/src/utils/db')
const PROVIDER_ID = process.env.STUB_PROVIDER_ID || 'devstub'
const PUBLIC_URL = (process.env.STUB_IDP_PUBLIC_URL || 'http://127.0.0.1:9099').replace(/\/$/, '')
const INTERNAL_URL = (process.env.STUB_IDP_INTERNAL_URL || PUBLIC_URL).replace(/\/$/, '')
// sub → dev account id. Keep the subs in sync with stub-idp.js PRINCIPALS.
const PRINCIPALS = [
{ sub: 'stub-admin', email: 'admin@dev.local', userId: 1 },
{ sub: 'stub-colby', email: 'colby@dev.local', userId: 14 },
]
async function main() {
// eslint-disable-next-line no-console
const log = (...a) => console.log('[seed-sso]', ...a)
await authProviders.save(PROVIDER_ID, {
kind: 'oauth2',
name: 'Dev Stub IdP',
enabled: true,
clientId: 'stub-client',
secret: 'stub-secret',
authorizeUrl: `${PUBLIC_URL}/authorize`,
tokenUrl: `${INTERNAL_URL}/token`,
userinfoUrl: `${INTERNAL_URL}/userinfo`,
scopes: 'openid email profile',
priority: 50,
})
log(`provider '${PROVIDER_ID}' upserted (authorize=${PUBLIC_URL}/authorize, token/userinfo=${INTERNAL_URL})`)
for (const p of PRINCIPALS) {
const existing = await userIdentities.findByProviderSubject(PROVIDER_ID, p.sub)
if (existing) {
log(`identity ${PROVIDER_ID}:${p.sub} already linked to user ${existing.user_id} — skip`)
continue
}
await userIdentities.link({ userId: p.userId, provider: PROVIDER_ID, subject: p.sub, email: p.email })
log(`linked ${PROVIDER_ID}:${p.sub} → user ${p.userId}`)
}
log('done.')
}
main()
.catch((err) => {
// eslint-disable-next-line no-console
console.error('[seed-sso] FAILED', err)
process.exitCode = 1
})
.finally(async () => {
try {
await close()
} catch {
/* ignore shutdown errors */
}
})

View File

@@ -0,0 +1,109 @@
#!/usr/bin/env node
/*
* Mobile SSO bridge smoketest — DEV ONLY, pairs with stub-idp.js.
*
* Drives the full native-SSO flow the Android app performs, headless, so the
* website bridge can be verified without an emulator/IdP:
* 1. mint PKCE (Layer B) + state
* 2. GET /auth/mobile/sso/start → 302 to the stub /authorize
* 3. follow the stub picker (inject `login_as`) → 302 to the website callback
* 4. website callback exchanges the IdP code server-side, resolves the linked
* user, and 302s to the app deep link runicgateway://auth/callback?code&state
* 5. POST /auth/mobile/sso/exchange { code, code_verifier } → the bearer pair
*
* Prereqs: stub-idp.js running, seed-sso-provider.js applied, website on :3000.
*
* Run: node website/scripts/dev/sso-bridge-smoketest.js [stub-colby|stub-admin]
* Env: BASE_URL (default http://127.0.0.1:3000), REDIRECT_URI
* (default runicgateway://auth/callback)
*/
'use strict'
const crypto = require('crypto')
const BASE = (process.env.BASE_URL || 'http://127.0.0.1:3000').replace(/\/$/, '')
const REDIRECT = process.env.REDIRECT_URI || 'runicgateway://auth/callback'
const PROVIDER = process.env.PROVIDER_ID || 'devstub'
const LOGIN_AS = process.argv[2] || 'stub-colby'
const b64url = (buf) => buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
// A minimal cookie jar: name → value, updated from every Set-Cookie.
const jar = {}
function storeCookies(res) {
const raw = res.headers.getSetCookie ? res.headers.getSetCookie() : res.headers.raw?.()['set-cookie'] || []
for (const c of raw) {
const [pair] = c.split(';')
const idx = pair.indexOf('=')
if (idx > 0) jar[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim()
}
}
const cookieHeader = () =>
Object.entries(jar)
.map(([k, v]) => `${k}=${v}`)
.join('; ')
async function step(name, url, opts = {}) {
const res = await fetch(url, { redirect: 'manual', headers: { Cookie: cookieHeader(), ...(opts.headers || {}) }, ...opts })
storeCookies(res)
const loc = res.headers.get('location')
console.log(`\n[${name}] ${res.status} ${url.split('?')[0]}`)
if (loc) console.log(` → Location: ${loc}`)
return { res, loc }
}
async function main() {
const verifier = b64url(crypto.randomBytes(32))
const challenge = b64url(crypto.createHash('sha256').update(verifier).digest())
const state = b64url(crypto.randomBytes(16))
console.log(`PKCE verifier=${verifier.slice(0, 12)}… challenge=${challenge.slice(0, 12)}… state=${state.slice(0, 12)}… loginAs=${LOGIN_AS}`)
// 2. start → 302 to stub /authorize
const startUrl =
`${BASE}/api/v1/auth/mobile/sso/start?provider=${PROVIDER}` +
`&code_challenge=${encodeURIComponent(challenge)}&state=${encodeURIComponent(state)}` +
`&redirect_uri=${encodeURIComponent(REDIRECT)}`
let { loc } = await step('start', startUrl)
if (!loc || !loc.includes('/authorize')) throw new Error('start did not redirect to the IdP authorize endpoint')
// 3. stub authorize: inject the account choice the picker would make.
const authUrl = new URL(loc)
authUrl.searchParams.set('login_as', LOGIN_AS)
;({ loc } = await step('idp-authorize', authUrl.toString()))
if (!loc || !loc.includes('/sso/')) throw new Error('IdP did not redirect back to the website callback')
// 4. website callback: server-side token+userinfo, resolve user, deep-link back.
;({ loc } = await step('callback', loc))
if (!loc) throw new Error('callback produced no redirect')
const deep = new URL(loc)
const appCode = deep.searchParams.get('code')
const appState = deep.searchParams.get('state')
const appErr = deep.searchParams.get('error')
if (appErr) throw new Error(`callback returned error to app: ${appErr}`)
if (!appCode) throw new Error(`callback did not deep-link a code (got ${loc})`)
if (appState !== state) throw new Error(`state mismatch: sent ${state}, got ${appState}`)
console.log(` ✓ deep link carries code=${appCode.slice(0, 10)}… state matches`)
// 5. exchange the app code + PKCE verifier for the bearer pair.
const exRes = await fetch(`${BASE}/api/v1/auth/mobile/sso/exchange`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: appCode, code_verifier: verifier, device_name: 'sso-smoketest' }),
})
const exBody = await exRes.json().catch(() => ({}))
console.log(`\n[exchange] ${exRes.status}`)
if (!exRes.ok) throw new Error(`exchange failed: ${exRes.status} ${JSON.stringify(exBody)}`)
const hasPair = exBody.accessToken && exBody.refreshToken
console.log(` user: ${JSON.stringify(exBody.user)}`)
console.log(` accessToken: ${exBody.accessToken ? exBody.accessToken.slice(0, 16) + '…' : '(none)'}`)
console.log(` refreshToken: ${exBody.refreshToken ? '(present)' : '(none)'}`)
if (!hasPair) throw new Error('exchange did not return an access/refresh pair')
console.log('\n✅ SSO bridge smoketest PASSED')
}
main().catch((err) => {
console.error('\n❌ SSO bridge smoketest FAILED:', err.message)
process.exitCode = 1
})

175
scripts/dev/stub-idp.js Normal file
View File

@@ -0,0 +1,175 @@
#!/usr/bin/env node
/*
* Stub OAuth2 / OIDC IdP — DEV ONLY.
*
* Dev environments have no real OAuth provider configured, so `GET /auth/providers`
* returns `[]` and the native mobile SSO flow can never be exercised. This tiny,
* dependency-free IdP stands in for Google/Discord/a custom OIDC so the full bridge
* (app → website `/auth/mobile/sso/start` → IdP → callback → `/exchange`) can be
* driven end-to-end against the local site. It mirrors the throwaway-stub precedent
* in `servuo-plugins/tools/stub_sidecar.ps1`.
*
* It implements the three endpoints `oauth2.provider.js` calls:
* GET /authorize → account picker, then 302 to redirect_uri?code&state
* POST /token → { access_token, token_type, expires_in }
* GET /userinfo → { sub, email, name } (Bearer <access_token>)
*
* Pair it with `seed-sso-provider.js`, which registers a matching `auth_providers`
* row and pre-links each principal's `sub` to a dev account (SSO is link-only).
*
* Run: node website/scripts/dev/stub-idp.js
* Env: STUB_IDP_PORT (default 9099), STUB_IDP_HOST (default 127.0.0.1)
*
* NEVER deploy this. It performs no credential checks and signs in anyone.
*/
'use strict'
const http = require('http')
const crypto = require('crypto')
const { URL, URLSearchParams } = require('url')
const PORT = Number(process.env.STUB_IDP_PORT || 9099)
const HOST = process.env.STUB_IDP_HOST || '127.0.0.1'
// Test principals the picker offers. Each `sub` must be pre-linked to a real dev
// account by seed-sso-provider.js, or the link-only login will reject it. Keep
// this list in sync with that script's PRINCIPALS.
const PRINCIPALS = [
{ sub: 'stub-admin', email: 'admin@dev.local', name: 'Dev Admin' },
{ sub: 'stub-colby', email: 'colby@dev.local', name: 'Dev Colby' },
]
// Short-lived in-memory maps: auth code → principal, access token → principal.
// Codes are single-use; both are cleared on process exit (dev only).
const codes = new Map()
const tokens = new Map()
function log(...args) {
// eslint-disable-next-line no-console
console.log(`[stub-idp ${new Date().toISOString()}]`, ...args)
}
function pickerPage(query) {
const rows = PRINCIPALS.map((p) => {
const q = new URLSearchParams(query)
q.set('login_as', p.sub)
return `<li><a href="/authorize?${q.toString()}">${p.name} &lt;${p.email}&gt; <code>${p.sub}</code></a></li>`
}).join('\n')
return `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Stub IdP</title>
<style>body{font:16px system-ui;margin:3rem auto;max-width:34rem}a{display:block;padding:.6rem;border:1px solid #ccc;border-radius:8px;margin:.4rem 0;text-decoration:none;color:#123}code{color:#888}</style>
<h1>Stub IdP — choose a test account</h1>
<p>DEV ONLY. Signs you in as the selected pre-linked identity.</p>
<ul style="list-style:none;padding:0">${rows}</ul>`
}
function sendJson(res, status, obj) {
const body = JSON.stringify(obj)
res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) })
res.end(body)
}
function handleAuthorize(req, res, url) {
const params = url.searchParams
const redirectUri = params.get('redirect_uri')
const state = params.get('state') || ''
const loginAs = params.get('login_as')
if (!redirectUri) {
res.writeHead(400, { 'Content-Type': 'text/plain' })
return res.end('missing redirect_uri')
}
// No account chosen yet → show the picker (preserving the OAuth query params).
if (!loginAs) {
const html = pickerPage(params)
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
return res.end(html)
}
const principal = PRINCIPALS.find((p) => p.sub === loginAs)
if (!principal) {
res.writeHead(400, { 'Content-Type': 'text/plain' })
return res.end(`unknown principal '${loginAs}'`)
}
// Mint a single-use authorization code bound to the principal and redirect back.
const code = crypto.randomBytes(24).toString('hex')
codes.set(code, principal)
const back = new URL(redirectUri)
back.searchParams.set('code', code)
if (state) back.searchParams.set('state', state)
log('authorize → issuing code for', principal.sub, '→', back.toString())
res.writeHead(302, { Location: back.toString() })
res.end()
}
function readBody(req) {
return new Promise((resolve) => {
let data = ''
req.on('data', (c) => {
data += c
})
req.on('end', () => resolve(data))
})
}
async function handleToken(req, res) {
const raw = await readBody(req)
const body = new URLSearchParams(raw)
const code = body.get('code')
const principal = code && codes.get(code)
if (!principal) {
log('token → invalid/expired code', code)
return sendJson(res, 400, { error: 'invalid_grant' })
}
codes.delete(code) // single-use
const accessToken = crypto.randomBytes(24).toString('hex')
tokens.set(accessToken, principal)
log('token → access token for', principal.sub)
return sendJson(res, 200, {
access_token: accessToken,
token_type: 'Bearer',
expires_in: 3600,
scope: body.get('scope') || 'openid email profile',
})
}
function handleUserinfo(req, res) {
const auth = req.headers.authorization || ''
const token = auth.startsWith('Bearer ') ? auth.slice(7) : null
const principal = token && tokens.get(token)
if (!principal) {
log('userinfo → missing/invalid bearer')
return sendJson(res, 401, { error: 'invalid_token' })
}
log('userinfo → returning profile for', principal.sub)
return sendJson(res, 200, { sub: principal.sub, email: principal.email, name: principal.name })
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`)
log(req.method, url.pathname)
try {
if (req.method === 'GET' && url.pathname === '/authorize') return handleAuthorize(req, res, url)
if (req.method === 'POST' && url.pathname === '/token') return await handleToken(req, res)
if (req.method === 'GET' && url.pathname === '/userinfo') return handleUserinfo(req, res)
if (req.method === 'GET' && url.pathname === '/') {
res.writeHead(200, { 'Content-Type': 'text/plain' })
return res.end('stub-idp OK. Endpoints: GET /authorize, POST /token, GET /userinfo')
}
res.writeHead(404, { 'Content-Type': 'text/plain' })
res.end('not found')
} catch (err) {
log('error', err)
sendJson(res, 500, { error: 'server_error' })
}
})
server.listen(PORT, HOST, () => {
log(`listening on http://${HOST}:${PORT}`)
log('authorize:', `http://${HOST}:${PORT}/authorize`)
log('token: ', `http://${HOST}:${PORT}/token`)
log('userinfo: ', `http://${HOST}:${PORT}/userinfo`)
log('principals:', PRINCIPALS.map((p) => p.sub).join(', '))
})

View File

@@ -0,0 +1,64 @@
// Custom node:test reporter that emits SonarQube's Generic Test Execution XML.
//
// Node's built-in reporters give us coverage (`lcov`) and pass/fail output
// (`spec`/`tap`/`junit`), but SonarQube's "Unit Tests" measure is fed by a
// SEPARATE report in *its own* format via `sonar.testExecutionReportPaths` — the
// lcov report only populates Coverage, which is why the dashboard shows coverage
// while the Unit Tests tile stays "-". This reporter produces that missing report.
//
// Format: https://docs.sonarsource.com/sonarqube/latest/analyzing-source-code/test-coverage/generic-test-data/
// <testExecutions version="1">
// <file path="server/test/foo.test.js">
// <testCase name="..." duration="12"/> <!-- duration = integer ms -->
// </file>
// </testExecutions>
//
// Paths are emitted repo-root-relative (POSIX separators) so they match the
// `sonar.tests` roots; the workflow runs `node --test` from the repo root, so the
// absolute `file` on each event strips cleanly against process.cwd().
import path from 'node:path'
function xmlEscape(s) {
return String(s).replace(/[<>&"']/g, (c) => ({
'<': '&lt;',
'>': '&gt;',
'&': '&amp;',
'"': '&quot;',
"'": '&apos;',
})[c])
}
export default async function* sonarTestReporter(source) {
const byFile = new Map()
const cwd = process.cwd()
for await (const event of source) {
if (event.type !== 'test:pass' && event.type !== 'test:fail') continue
const d = event.data
// Skip the container events (a `describe` suite) and anything without a file
// — only real test cases go in the report, so the count matches the runner's.
if (!d.file || (d.details && d.details.type === 'suite')) continue
const rel = path.relative(cwd, d.file).split(path.sep).join('/')
if (!byFile.has(rel)) byFile.set(rel, [])
byFile.get(rel).push({
name: d.name,
duration: Math.max(0, Math.round(d.details?.duration_ms ?? 0)),
failed: event.type === 'test:fail',
skipped: Boolean(d.skip || d.todo),
})
}
yield '<?xml version="1.0" encoding="UTF-8"?>\n<testExecutions version="1">\n'
for (const [file, cases] of byFile) {
yield ` <file path="${xmlEscape(file)}">\n`
for (const c of cases) {
const attrs = `name="${xmlEscape(c.name)}" duration="${c.duration}"`
if (c.failed) yield ` <testCase ${attrs}><failure message="test failed"/></testCase>\n`
else if (c.skipped) yield ` <testCase ${attrs}><skipped/></testCase>\n`
else yield ` <testCase ${attrs}/>\n`
}
yield ' </file>\n'
}
yield '</testExecutions>\n'
}

View File

@@ -27,6 +27,15 @@ JWT_EXPIRES_IN=1d
COOKIE_SECURE=auto COOKIE_SECURE=auto
COOKIE_NAME=rg_token COOKIE_NAME=rg_token
# Trusted-device MFA ("Trust this device"). The trust cookie's name, how long a
# device stays trusted (skips the TOTP step, never the password), the per-user cap
# (no silent pruning — an over-cap trust is refused), and how many single-use
# recovery codes are generated at 2FA enrollment.
TRUST_COOKIE_NAME=rg_trust
TRUSTED_DEVICE_TTL_DAYS=30
MAX_TRUSTED_DEVICES=10
RECOVERY_CODE_COUNT=10
# Encryption key for secrets stored at rest (OAuth client secrets in auth_providers). # Encryption key for secrets stored at rest (OAuth client secrets in auth_providers).
# Any string — hashed to a 256-bit AES-GCM key. REQUIRED in production; in dev an # Any string — hashed to a 256-bit AES-GCM key. REQUIRED in production; in dev an
# insecure key is derived from JWT_SECRET if unset (with a warning). # insecure key is derived from JWT_SECRET if unset (with a warning).

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