Compare commits
51 Commits
a4ef9d676d
...
edge
| Author | SHA1 | Date | |
|---|---|---|---|
| b649345484 | |||
| a103e0ce10 | |||
| 0a3f1eb9fa | |||
| a45a3d120a | |||
| e3c999b704 | |||
| e0927bc255 | |||
| fe83c91ba9 | |||
| 291c30f6ff | |||
| 85f563fc16 | |||
| 32ed8e4411 | |||
| 21196466ed | |||
| 39eaae90a8 | |||
| 97f19b4221 | |||
| 6195c76d61 | |||
| bd749d4f1f | |||
| 2892d01b24 | |||
| 7780fb033b | |||
| ec1ca7e794 | |||
| dcf6ef1886 | |||
| 3add0063bf | |||
| f1dda8fe66 | |||
| 4691fd6633 | |||
| 265042eaa5 | |||
| 18815f4c7a | |||
| 15cefe5ea1 | |||
| b517d7b2df | |||
| 78f994955c | |||
| 32a3ff104a | |||
| 42a403ad2e | |||
| 847cfd2d2b | |||
| 02580ebda3 | |||
| 3d6b2e23a7 | |||
| 0a2ccafff6 | |||
| ec0036ce6d | |||
| d765280e28 | |||
| 03534c8db1 | |||
| 5103b74a9d | |||
| c91fd128bf | |||
| 01a559792c | |||
| e50fab241f | |||
| 779a304173 | |||
| c6c0c257dd | |||
| 8771a1cf6c | |||
| 8da658f223 | |||
| bda031566a | |||
| b61a4d6721 | |||
| 1e1a3d67c3 | |||
| 26094459ae | |||
| bfa1db58c4 | |||
| 7c769ea8fd | |||
| f3d084e046 |
@@ -10,5 +10,8 @@ uploads
|
||||
server/logs
|
||||
logs
|
||||
*.log
|
||||
# Installed modules are mounted at runtime, never baked into the image. Without
|
||||
# this a module in the builder's working tree would ship inside every image.
|
||||
modules
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
@@ -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.
|
||||
UOLINK_BASE_URL=http://127.0.0.1:8080
|
||||
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 ───
|
||||
# The `ntfy` compose service and the backend's push fan-out (opt-in notifications
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
# Gate every pull request into `main` on a fast, DB-free check suite so a broken
|
||||
# build or failing test can't reach the deployable branch. Complements
|
||||
# Gate every pull request into `main` or `edge` on a fast, DB-free check suite so
|
||||
# a broken build or failing test can't reach either integration branch. Complements
|
||||
# build-images.yml, which runs only AFTER merge (on push to main) to publish
|
||||
# images — this one runs BEFORE merge.
|
||||
#
|
||||
# `edge` is listed as well as `main` because long workstreams land phase by phase
|
||||
# on `edge` and reach `main` as a single cutover (the module system, protocol v3).
|
||||
# With `branches: [main]` alone, every one of those phase PRs merges with NO checks
|
||||
# at all and the entire workstream runs blind until the cutover — which is exactly
|
||||
# what happened to the nine Android M12 phase PRs in that repo. A branch that
|
||||
# accumulates work for weeks needs the gate more than `main` does, not less.
|
||||
#
|
||||
# Enforcement (one-time, in the Gitea UI):
|
||||
# Repository Settings → Branches → Branch Protection (rule for `main`)
|
||||
# • Enable Status Check
|
||||
@@ -10,6 +17,10 @@
|
||||
# Note: Gitea only lists a context in its dropdown after it has reported once,
|
||||
# so let this workflow run on one PR first. The `PR Checks / *` glob matches
|
||||
# without needing the dropdown.
|
||||
# The workflow now RUNS on PRs into `edge` too, but running is not enforcing:
|
||||
# blocking a red phase PR needs its own protection rule for `edge`, with the
|
||||
# same `PR Checks / *` pattern. Without one the checks report and merging stays
|
||||
# possible anyway.
|
||||
#
|
||||
# Runner: reuses the existing self-hosted `ubuntu-latest` runner. These jobs need
|
||||
# only Node (no Docker socket), and the server tests stub their models + point the
|
||||
@@ -19,7 +30,7 @@ name: PR Checks
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [main, edge]
|
||||
|
||||
# A newer push to the same PR cancels the in-flight run.
|
||||
concurrency:
|
||||
|
||||
18
.gitignore
vendored
18
.gitignore
vendored
@@ -21,6 +21,13 @@ uploads/
|
||||
server/logs/
|
||||
logs/
|
||||
|
||||
# Installed modules (docs/website/MODULE_SYSTEM.md). Core ships no module, so
|
||||
# anything here is an operator's install or a developer's scratch copy. The
|
||||
# directory itself IS tracked, via its README: docker-compose.yml bind-mounts it,
|
||||
# and a missing bind-mount source is recreated by Docker as root-owned.
|
||||
modules/*
|
||||
!modules/README.md
|
||||
|
||||
# Operator-supplied spawn atlas artwork. Creature art is never committed: sprites
|
||||
# are extracted from the operator's own UO client .mul/.uop files and are theirs,
|
||||
# not ours to redistribute. The images live under server/uploads/atlas/, already
|
||||
@@ -28,6 +35,17 @@ logs/
|
||||
# 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/
|
||||
|
||||
|
||||
@@ -21,6 +21,12 @@ RUN if [ -f client/package.json ]; then \
|
||||
# Persistent uploads + logs live on mounted volumes.
|
||||
RUN mkdir -p /app/uploads /app/logs && chown -R node:node /app/uploads /app/logs
|
||||
|
||||
# Installed modules are mounted in too (docker-compose.yml), and .dockerignore
|
||||
# keeps any local modules/ OUT of the image — a module must never be baked in.
|
||||
# The directory is still created here so a container run without the mount finds
|
||||
# an empty, writable modules dir rather than no directory at all.
|
||||
RUN mkdir -p /app/modules && chown node:node /app/modules
|
||||
|
||||
USER node
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
35
README.md
35
README.md
@@ -222,6 +222,10 @@ IMAGE_TAG=sha-042a151 docker compose pull && docker compose up -d
|
||||
- Health check: `GET http://localhost:3000/api/health` → `{ "status": "ok" }`
|
||||
- Logs: `docker compose logs -f app` (and `./logs/app.log` on the host)
|
||||
- Stop: `docker compose down` (add `-v` to also wipe the database + uploads volumes)
|
||||
- Modules: installed into `./modules` on the host (bind-mounted to `/app/modules`), never baked into
|
||||
the image — an operator adds one to a pull-only deployment without building anything. Adding or
|
||||
removing one takes a `docker compose restart app`; the scan is synchronous at startup. See
|
||||
[`modules/README.md`](modules/README.md).
|
||||
|
||||
**Build the images locally instead of pulling** (offline, or to test an unmerged change) — overlay
|
||||
the dev file, which adds `build:` back:
|
||||
@@ -391,9 +395,10 @@ npm run routes:manifest -- --check # exit 1 if either file is stale (what CI ru
|
||||
|
||||
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.
|
||||
`/api/**` and `/.well-known/**` plus the internal listener. The SPA catch-all, `/uploads`, `/brand`
|
||||
and installed modules' `/modules/<id>` chunks are filesystem-conditional static mounts, not API
|
||||
contract, so they are excluded and the output depends neither on whether the client has been built
|
||||
nor on which modules are mounted.
|
||||
|
||||
Two generated files, two very different meanings:
|
||||
|
||||
@@ -416,6 +421,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
|
||||
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
|
||||
|
||||
```
|
||||
@@ -484,6 +512,7 @@ Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`.
|
||||
| `NODE_ENV` | `production` | |
|
||||
| `PORT` | `3000` | server listens on `0.0.0.0:PORT` |
|
||||
| `UPLOAD_DIR` | `<server>/uploads` | where post images are written (`/app/uploads`, volume-mounted, in Compose) |
|
||||
| `MODULES_DIR` | `<repo>/modules` | where installed modules are scanned from (`/app/modules`, bind-mounted, in Compose) |
|
||||
| `DB_HOST` / `DB_PORT` | `db` / `3306` | `db` in Compose; `127.0.0.1` for local dev |
|
||||
| `DB_NAME` / `DB_USER` / `DB_PASSWORD` | `runic_gateway` / `runic` / — | app database credentials |
|
||||
| `DB_ROOT_PASSWORD` | — | MariaDB root (Compose only) |
|
||||
|
||||
@@ -1,13 +1,83 @@
|
||||
// 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
|
||||
// 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()
|
||||
|
||||
const name = process.env.BRAND_NAME || 'Runic Gateway'
|
||||
const accentHex = process.env.BRAND_ACCENT_COLOR || '#7f99bd'
|
||||
const accentInt = (() => {
|
||||
const n = parseInt(String(accentHex).replace('#', ''), 16)
|
||||
return Number.isNaN(n) ? 0x7f99bd : n
|
||||
})()
|
||||
const siteApi = require('./site/siteApiClient')
|
||||
const createLogger = require('./utils/logger')
|
||||
|
||||
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(),
|
||||
}
|
||||
|
||||
@@ -21,6 +21,11 @@ async function start() {
|
||||
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()
|
||||
|
||||
setupShutdown(server)
|
||||
|
||||
@@ -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) {
|
||||
return call(`/posts/news/${encodeURIComponent(idOrSlug)}`)
|
||||
}
|
||||
@@ -39,4 +48,4 @@ function searchWiki(query) {
|
||||
return call(`/wiki?q=${encodeURIComponent(query)}`)
|
||||
}
|
||||
|
||||
module.exports = { getNewsPost, searchWiki }
|
||||
module.exports = { getPublicSettings, getNewsPost, searchWiki }
|
||||
|
||||
@@ -7,7 +7,16 @@
|
||||
<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.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>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
62
client/package-lock.json
generated
62
client/package-lock.json
generated
@@ -8,6 +8,9 @@
|
||||
"name": "runic-gateway-client",
|
||||
"version": "1.0.0",
|
||||
"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-link": "^2.27.2",
|
||||
"@tiptap/extension-text-align": "^2.27.2",
|
||||
@@ -306,6 +309,59 @@
|
||||
"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": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
||||
@@ -2488,6 +2544,12 @@
|
||||
"@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": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
"test": "node --test"
|
||||
},
|
||||
"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-link": "^2.27.2",
|
||||
"@tiptap/extension-text-align": "^2.27.2",
|
||||
|
||||
@@ -5,6 +5,8 @@ import MaintenanceGate from './components/MaintenanceGate.jsx'
|
||||
import RequireAuth from './components/RequireAuth.jsx'
|
||||
import RequirePlayer from './components/RequirePlayer.jsx'
|
||||
import RoleGate from './components/RoleGate.jsx'
|
||||
import { routesFor } from './modules/registry.js'
|
||||
import { ModuleFeaturesProvider } from './modules/features.jsx'
|
||||
|
||||
// Public
|
||||
import Portal from './routes/public/Portal.jsx'
|
||||
@@ -23,6 +25,11 @@ import Guilds from './routes/public/Guilds.jsx'
|
||||
import Governors from './routes/public/Governors.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 WikiArticle from './routes/wiki/WikiArticle.jsx'
|
||||
import CmsPage from './routes/public/CmsPage.jsx'
|
||||
@@ -36,12 +43,15 @@ import PagesAdmin from './routes/admin/views/PagesAdmin.jsx'
|
||||
import PageBuilder from './routes/admin/views/PageBuilder.jsx'
|
||||
import WikiAdmin from './routes/admin/views/WikiAdmin.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 ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx'
|
||||
import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx'
|
||||
import DiscordBotAdmin from './routes/admin/views/DiscordBotAdmin.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 AdminCharacters from './routes/admin/views/AdminCharacters.jsx'
|
||||
import AdminCharacter from './routes/admin/views/AdminCharacter.jsx'
|
||||
@@ -71,128 +81,198 @@ export default function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<SiteProvider>
|
||||
<Routes>
|
||||
{/* Landing hero — always public, even in maintenance mode. The hero is
|
||||
itself the pre-launch "coming soon" page, so it sits outside the
|
||||
MaintenanceGate and every visitor sees it regardless of auth/site mode. */}
|
||||
<Route path="/" element={<Portal />} />
|
||||
{/* Inside the auth and site contexts, because a feature provider is a
|
||||
hook that may well read either — the shard one does, indirectly, by
|
||||
asking an endpoint whose answer depends on the session. Outside the
|
||||
routes, so the nav in every layout is filtered by the same gate and
|
||||
the provider hooks are called once for the whole app rather than
|
||||
once per screen. */}
|
||||
<ModuleFeaturesProvider>
|
||||
<Routes>
|
||||
{/* Landing hero — always public, even in maintenance mode. The hero is
|
||||
itself the pre-launch "coming soon" page, so it sits outside the
|
||||
MaintenanceGate and every visitor sees it regardless of auth/site mode. */}
|
||||
<Route path="/" element={<Portal />} />
|
||||
|
||||
{/* Rest of the public site — gated by maintenance mode (admins preview through it) */}
|
||||
<Route
|
||||
element={
|
||||
<MaintenanceGate>
|
||||
<Outlet />
|
||||
</MaintenanceGate>
|
||||
}
|
||||
>
|
||||
<Route path="/site" element={<Website />} />
|
||||
<Route path="/site/news" element={<News />} />
|
||||
<Route path="/site/screenshots" element={<Screenshots />} />
|
||||
<Route path="/site/five-on-friday" element={<FiveOnFriday />} />
|
||||
<Route path="/site/newsletter" element={<Newsletter />} />
|
||||
<Route path="/site/newsletter/:id" element={<NewsletterIssue />} />
|
||||
<Route path="/site/about" element={<About />} />
|
||||
<Route path="/site/status" element={<Status />} />
|
||||
<Route path="/site/shard" element={<Shard />} />
|
||||
<Route path="/site/shard/activity" element={<ShardActivity />} />
|
||||
<Route path="/site/champs" element={<ChampSpawns />} />
|
||||
<Route path="/site/guilds" element={<Guilds />} />
|
||||
<Route path="/site/governors" element={<Governors />} />
|
||||
<Route path="/site/houses" element={<Houses />} />
|
||||
<Route path="/site/rules" element={<Rules />} />
|
||||
<Route path="/wiki" element={<Wiki />} />
|
||||
<Route path="/wiki/:slug" element={<WikiArticle />} />
|
||||
{/* CMS pages: top-level /:slug, matched only after the named routes
|
||||
above (React Router ranks static routes over this dynamic one). */}
|
||||
<Route path="/:slug" element={<CmsPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Draft-preview link (token-gated). Outside the maintenance gate so a
|
||||
preview link works regardless of site mode. */}
|
||||
<Route path="/preview/:id/:token" element={<CmsPage preview />} />
|
||||
|
||||
{/* Admin */}
|
||||
<Route path="/admin/login" element={<AdminLogin />} />
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<AdminLayout />
|
||||
</RequireAuth>
|
||||
}
|
||||
>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="posts" element={<PostsAdmin />} />
|
||||
<Route path="pages" element={<PagesAdmin />} />
|
||||
<Route path="pages/new" element={<PageBuilder />} />
|
||||
<Route path="pages/:id" element={<PageBuilder />} />
|
||||
<Route path="wiki" element={<WikiAdmin />} />
|
||||
<Route path="hero" element={<HeroEditor />} />
|
||||
<Route path="settings" element={<SettingsAdmin />} />
|
||||
{/* Rest of the public site — gated by maintenance mode (admins preview through it) */}
|
||||
<Route
|
||||
path="moderation"
|
||||
element={
|
||||
<RoleGate roles={['admin', 'moderator']}>
|
||||
<MaintenanceGate>
|
||||
<Outlet />
|
||||
</RoleGate>
|
||||
</MaintenanceGate>
|
||||
}
|
||||
>
|
||||
<Route index element={<Moderation />} />
|
||||
<Route path="user/:discordId" element={<ModerationUser />} />
|
||||
<Route path="appeals" element={<Appeals />} />
|
||||
<Route path="/site" element={<Website />} />
|
||||
<Route path="/site/news" element={<News />} />
|
||||
<Route path="/site/screenshots" element={<Screenshots />} />
|
||||
<Route path="/site/five-on-friday" element={<FiveOnFriday />} />
|
||||
<Route path="/site/newsletter" element={<Newsletter />} />
|
||||
<Route path="/site/newsletter/:id" element={<NewsletterIssue />} />
|
||||
<Route path="/site/about" element={<About />} />
|
||||
<Route path="/site/status" element={<Status />} />
|
||||
<Route path="/site/shard" element={<Shard />} />
|
||||
<Route path="/site/shard/activity" element={<ShardActivity />} />
|
||||
<Route path="/site/champs" element={<ChampSpawns />} />
|
||||
<Route path="/site/guilds" element={<Guilds />} />
|
||||
<Route path="/site/governors" element={<Governors />} />
|
||||
<Route path="/site/houses" element={<Houses />} />
|
||||
<Route path="/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/:slug" element={<WikiArticle />} />
|
||||
{/* Installed modules' public pages, namespaced `/<id>/…` — the
|
||||
registry prefixes the segment, so a module cannot spell its way
|
||||
out of it (docs/website/MODULE_API.md §3.3). Declared before the
|
||||
CMS catch-all below: React Router ranks a static segment over a
|
||||
dynamic one, so the order is not what saves us, but keeping the
|
||||
two adjacent makes the relationship visible to whoever adds the
|
||||
next route here. */}
|
||||
{routesFor('public').map((r) => (
|
||||
<Route key={r.path} path={`/${r.path}`} element={r.element} />
|
||||
))}
|
||||
{/* CMS pages: top-level /:slug, matched only after the named routes
|
||||
above (React Router ranks static routes over this dynamic one). */}
|
||||
<Route path="/:slug" element={<CmsPage />} />
|
||||
</Route>
|
||||
<Route path="activity" element={<ActivityAdmin />} />
|
||||
<Route path="bot-activity" element={<BotActivityAdmin />} />
|
||||
<Route path="discord-bot" element={<DiscordBotAdmin />} />
|
||||
<Route path="shard" element={<ShardAdmin />} />
|
||||
<Route path="shard-visibility" element={<ShardVisibility />} />
|
||||
<Route
|
||||
path="shard-ops"
|
||||
element={
|
||||
<RoleGate roles={['admin', 'moderator']}>
|
||||
<ShardOps />
|
||||
</RoleGate>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="houses"
|
||||
element={
|
||||
<RoleGate roles={['admin', 'moderator']}>
|
||||
<HousesAdmin />
|
||||
</RoleGate>
|
||||
}
|
||||
/>
|
||||
<Route path="characters" element={<AdminCharacters />} />
|
||||
<Route path="characters/:serial" element={<AdminCharacter />} />
|
||||
<Route path="auth-providers" element={<AuthProvidersAdmin />} />
|
||||
<Route path="users" element={<UsersAdmin />} />
|
||||
<Route path="users/:id" element={<UserDetail />} />
|
||||
<Route path="invites" element={<InvitesAdmin />} />
|
||||
<Route path="account" element={<AccountAdmin />} />
|
||||
<Route path="*" element={<Navigate to="/admin" replace />} />
|
||||
</Route>
|
||||
|
||||
{/* Player portal */}
|
||||
<Route path="/account/login" element={<PlayerLogin />} />
|
||||
<Route path="/account/register" element={<PlayerRegister />} />
|
||||
<Route path="/account/forgot" element={<ForgotPassword />} />
|
||||
<Route path="/account/reset/:token" element={<ResetPassword />} />
|
||||
<Route path="/invite/:token" element={<AcceptInvite />} />
|
||||
<Route
|
||||
element={
|
||||
<RequirePlayer>
|
||||
<PlayerPortalLayout />
|
||||
</RequirePlayer>
|
||||
}
|
||||
>
|
||||
<Route path="/player" element={<PlayerCharacters />} />
|
||||
<Route path="/player/char/:serial" element={<PlayerCharacter />} />
|
||||
<Route path="/account" element={<PlayerAccount />} />
|
||||
<Route path="/account/appeals" element={<PlayerAppeals />} />
|
||||
</Route>
|
||||
{/* Draft-preview link (token-gated). Outside the maintenance gate so a
|
||||
preview link works regardless of site mode. */}
|
||||
<Route path="/preview/:id/:token" element={<CmsPage preview />} />
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
{/* Admin */}
|
||||
<Route path="/admin/login" element={<AdminLogin />} />
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<AdminLayout />
|
||||
</RequireAuth>
|
||||
}
|
||||
>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="posts" element={<PostsAdmin />} />
|
||||
<Route path="pages" element={<PagesAdmin />} />
|
||||
<Route path="pages/new" element={<PageBuilder />} />
|
||||
<Route path="pages/:id" element={<PageBuilder />} />
|
||||
<Route path="wiki" element={<WikiAdmin />} />
|
||||
<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="moderation"
|
||||
element={
|
||||
<RoleGate roles={['admin', 'moderator']}>
|
||||
<Outlet />
|
||||
</RoleGate>
|
||||
}
|
||||
>
|
||||
<Route index element={<Moderation />} />
|
||||
<Route path="user/:discordId" element={<ModerationUser />} />
|
||||
<Route path="appeals" element={<Appeals />} />
|
||||
</Route>
|
||||
<Route path="activity" element={<ActivityAdmin />} />
|
||||
<Route path="bot-activity" element={<BotActivityAdmin />} />
|
||||
<Route path="discord-bot" element={<DiscordBotAdmin />} />
|
||||
<Route path="shard" element={<ShardAdmin />} />
|
||||
<Route path="shard-visibility" element={<ShardVisibility />} />
|
||||
<Route path="shard-atlas" element={<SpawnAtlasAdmin />} />
|
||||
<Route
|
||||
path="shard-ops"
|
||||
element={
|
||||
<RoleGate roles={['admin', 'moderator']}>
|
||||
<ShardOps />
|
||||
</RoleGate>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="houses"
|
||||
element={
|
||||
<RoleGate roles={['admin', 'moderator']}>
|
||||
<HousesAdmin />
|
||||
</RoleGate>
|
||||
}
|
||||
/>
|
||||
<Route path="characters" element={<AdminCharacters />} />
|
||||
<Route path="characters/:serial" element={<AdminCharacter />} />
|
||||
<Route path="auth-providers" element={<AuthProvidersAdmin />} />
|
||||
<Route path="users" element={<UsersAdmin />} />
|
||||
<Route path="users/:id" element={<UserDetail />} />
|
||||
<Route path="invites" element={<InvitesAdmin />} />
|
||||
<Route path="account" element={<AccountAdmin />} />
|
||||
{/* Installed modules' admin pages, at /admin/<id>/…, already inside
|
||||
RequireAuth + AdminLayout. A module cannot supply its own auth
|
||||
wrapper — only an optional { roles }, which core applies as the
|
||||
same RoleGate its own routes above use, so the sidebar and the
|
||||
route table cannot disagree about who may see what. Before the
|
||||
`*` redirect, which would otherwise swallow every one of them. */}
|
||||
{routesFor('admin').map((r) => (
|
||||
<Route
|
||||
key={r.path}
|
||||
path={r.path}
|
||||
element={r.gate ? <RoleGate roles={r.gate.roles}>{r.element}</RoleGate> : r.element}
|
||||
/>
|
||||
))}
|
||||
<Route path="*" element={<Navigate to="/admin" replace />} />
|
||||
</Route>
|
||||
|
||||
{/* Player portal */}
|
||||
<Route path="/account/login" element={<PlayerLogin />} />
|
||||
<Route path="/account/register" element={<PlayerRegister />} />
|
||||
<Route path="/account/forgot" element={<ForgotPassword />} />
|
||||
<Route path="/account/reset/:token" element={<ResetPassword />} />
|
||||
<Route path="/invite/:token" element={<AcceptInvite />} />
|
||||
<Route
|
||||
element={
|
||||
<RequirePlayer>
|
||||
<PlayerPortalLayout />
|
||||
</RequirePlayer>
|
||||
}
|
||||
>
|
||||
<Route path="/player" element={<PlayerCharacters />} />
|
||||
<Route path="/player/char/:serial" element={<PlayerCharacter />} />
|
||||
<Route path="/account" element={<PlayerAccount />} />
|
||||
<Route path="/account/appeals" element={<PlayerAppeals />} />
|
||||
{/* Installed modules' player-portal pages, at /player/<id>/…. This
|
||||
group's own routes are absolute (its layout route has no path),
|
||||
so the prefix is written here rather than inherited — the one
|
||||
place the three areas do not read alike. */}
|
||||
{routesFor('player').map((r) => (
|
||||
<Route
|
||||
key={r.path}
|
||||
path={`/player/${r.path}`}
|
||||
element={r.gate ? <RoleGate roles={r.gate.roles}>{r.element}</RoleGate> : r.element}
|
||||
/>
|
||||
))}
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</ModuleFeaturesProvider>
|
||||
</SiteProvider>
|
||||
</AuthProvider>
|
||||
)
|
||||
|
||||
@@ -42,6 +42,17 @@ function safeParse(text) {
|
||||
}
|
||||
}
|
||||
|
||||
// The request PRIMITIVE, exported for installed modules and handed to them on
|
||||
// `window.__rg.api` (docs/website/MODULE_API.md §3.5). Core owns the fetch
|
||||
// semantics — same-origin /api/v1, cookies included, JSON in and out, ApiError
|
||||
// on a non-2xx — and nothing above them: a module owns the paths it calls,
|
||||
// because it owns the routes at the other end.
|
||||
//
|
||||
// The `api` object below stays core's own binding surface. Its `atlas` and
|
||||
// `shard` namespaces are module bindings that only still live here because
|
||||
// Phase 3 has not moved them yet.
|
||||
export { req as request }
|
||||
|
||||
export const api = {
|
||||
// ----- auth -----
|
||||
me: () => req('/auth/me'),
|
||||
@@ -97,6 +108,14 @@ export const api = {
|
||||
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 -----
|
||||
publicSettings: () => req('/public/settings'),
|
||||
status: () => req('/public/status'),
|
||||
@@ -150,10 +169,73 @@ export const api = {
|
||||
// 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
|
||||
// fetch-only, so SSE subscribers build the URL from here. The admin stream
|
||||
// carries every kind (incl. audit/cheat) and needs the staff session cookie.
|
||||
@@ -217,6 +299,20 @@ export const api = {
|
||||
deleteWikiCategory: (id) => req(`/admin/wiki/categories/${id}`, { method: 'DELETE' }),
|
||||
getSettings: () => req('/admin/settings'),
|
||||
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}`),
|
||||
botActivity: () => req('/admin/bot-activity'),
|
||||
unbanIp: (ip) => req('/admin/bot-activity/unban', { method: 'POST', body: { ip } }),
|
||||
@@ -360,6 +456,18 @@ export const api = {
|
||||
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) -----
|
||||
// `actor` is stamped server-side from the session — never sent from here.
|
||||
shardOps: {
|
||||
|
||||
33
client/src/components/BrandLogo.jsx
Normal file
33
client/src/components/BrandLogo.jsx
Normal 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 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -10,24 +10,89 @@ import ShardAccountActions from './ShardAccountActions.jsx'
|
||||
|
||||
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
|
||||
// computed display strings; reward entries may be a cliloc NUMBER-as-string or a
|
||||
// literal string. Without a cliloc table on the site we can only show literals, so
|
||||
// numeric reward entries are skipped rather than shown as a raw number. Returns a
|
||||
// de-duped list of human-readable title chips.
|
||||
// literal string.
|
||||
//
|
||||
// `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) {
|
||||
if (!titles) return []
|
||||
const out = []
|
||||
if (titles.fameKarma) out.push(titles.fameKarma)
|
||||
if (titles.skill) out.push(titles.skill)
|
||||
const reward = Array.isArray(titles.reward) ? titles.reward : []
|
||||
const 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
|
||||
// Prefer the selected reward title; fall back to the first literal one.
|
||||
const candidate = sel >= 0 && sel < reward.length ? reward[sel] : reward.find((r) => r && !/^\d+$/.test(String(r)))
|
||||
if (candidate && !/^\d+$/.test(String(candidate))) out.push(String(candidate))
|
||||
// Prefer the selected reward title; fall back to the first one that resolved.
|
||||
// The `??` matters: a selected title whose cliloc did not resolve must fall
|
||||
// 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))]
|
||||
}
|
||||
|
||||
// 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)' }) {
|
||||
return (
|
||||
<span
|
||||
@@ -75,6 +140,11 @@ export default function CharacterSheet({ char, moderation = false }) {
|
||||
.filter((s) => (s.value || s.base || 0) > 0)
|
||||
.sort((a, b) => (b.value || 0) - (a.value || 0))
|
||||
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 (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 22 }}>
|
||||
@@ -173,17 +243,37 @@ export default function CharacterSheet({ char, moderation = false }) {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Loyalty & points — one entry per system this character has scored in */}
|
||||
{points.length > 0 && (
|
||||
<section>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>
|
||||
Loyalty & 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.length > 0 && (
|
||||
<section>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>Equipment</div>
|
||||
<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 }}>
|
||||
<span style={{ flex: 'none', width: 22, height: 22, borderRadius: 5, border: '1px solid var(--line)', background: 'rgba(255,255,255,0.05)' }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.88rem' }}>{it.layer || 'Item'}</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.74rem' }}>id {it.itemId}{it.hue ? ` · hue ${it.hue}` : ''}</div>
|
||||
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.88rem' }}>{label}</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.74rem' }}>{detail.filter(Boolean).join(' · ')}</div>
|
||||
</div>
|
||||
{it.mods && Object.keys(it.mods).length > 0 && (
|
||||
<div className="sans" style={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'flex-end', maxWidth: '55%' }}>
|
||||
@@ -193,7 +283,8 @@ export default function CharacterSheet({ char, moderation = false }) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
150
client/src/components/NavDropdown.jsx
Normal file
150
client/src/components/NavDropdown.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,17 +1,32 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Link, NavLink } from 'react-router-dom'
|
||||
import MoonDot from './MoonDot.jsx'
|
||||
import BrandLogo from './BrandLogo.jsx'
|
||||
import { useAuth } from '../contexts/AuthContext.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'
|
||||
import { withModuleNav } from '../modules/nav.js'
|
||||
import { useFeatureGate } from '../modules/features.jsx'
|
||||
|
||||
// One consistent top nav for the whole public site. Every page gets the same
|
||||
// main links plus an auth-aware entry on the right (Sign in / My Account / Admin).
|
||||
//
|
||||
// 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.
|
||||
const NAV = [
|
||||
// Entries carrying a `feature` are 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. Which module
|
||||
// answers for a given flag is the registry's business now, not this file's —
|
||||
// core registers `useShardFlags` for the ten below and Phase 3 hands them over
|
||||
// (modules/featureGate.js).
|
||||
//
|
||||
// Exported because Admin -> Navigation edits this list. It stays declared here,
|
||||
// with this component as its owner: the editor may only relabel, reorder and
|
||||
// hide what it finds, and `to`/`feature` are never its to change (§7). An
|
||||
// installed module's rows join it in `withModuleNav` below — before the override
|
||||
// merge, so an admin can edit those rows exactly as they edit these.
|
||||
export const NAV = [
|
||||
{ label: 'Home', to: '/', end: true },
|
||||
{ label: 'News', to: '/site/news' },
|
||||
{ label: 'Screenshots', to: '/site/screenshots' },
|
||||
@@ -24,6 +39,9 @@ const NAV = [
|
||||
{ label: 'Governors', to: '/site/governors', feature: 'governors' },
|
||||
{ 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' },
|
||||
]
|
||||
|
||||
@@ -35,9 +53,29 @@ const linkStyle = ({ isActive }) => ({
|
||||
|
||||
export default function SiteHeader() {
|
||||
const { user, loading } = useAuth()
|
||||
const { siteTitle } = useSite()
|
||||
const shardFeatures = useShardFeatures()
|
||||
const nav = NAV.filter((item) => !item.feature || canSee(shardFeatures, item.feature))
|
||||
const { siteTitle, settings } = useSite()
|
||||
const isVisible = useFeatureGate()
|
||||
|
||||
// Core's rows plus every installed module's. Computed once: the registry is
|
||||
// fixed before the first render and there is no unregistering, so this cannot
|
||||
// change during a session (modules/nav.js).
|
||||
const baseNav = useMemo(() => withModuleNav(NAV, 'public'), [])
|
||||
|
||||
// An admin may relabel, reorder and hide these entries from Admin →
|
||||
// Navigation, and may group them into dropdown sections alongside links of
|
||||
// their own (THEMING_AND_NAV.md §7). Two things about the order here:
|
||||
//
|
||||
// • the override merge runs FIRST and the feature filter after it, so the
|
||||
// filter stays the boundary — an override cannot un-hide a 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(baseNav, parseJsonSetting(settings.nav_public))
|
||||
return pruneNav(tree, isVisible)
|
||||
}, [baseNav, settings.nav_public, isVisible])
|
||||
|
||||
// Where the auth entry points: staff → admin, player → portal, else sign in.
|
||||
let account
|
||||
@@ -65,15 +103,20 @@ export default function SiteHeader() {
|
||||
className="display"
|
||||
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 />
|
||||
{siteTitle}
|
||||
</Link>
|
||||
<nav style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
||||
{nav.map((l) => (
|
||||
<NavLink key={l.to} to={l.to} end={l.end} className="pill" style={linkStyle}>
|
||||
{l.label}
|
||||
</NavLink>
|
||||
))}
|
||||
{nav.map((l) =>
|
||||
l.kind === 'section' ? (
|
||||
<NavDropdown key={l.id} label={l.label} items={l.items} linkStyle={linkStyle} />
|
||||
) : (
|
||||
<NavLink key={l.kind === 'link' ? l.id : l.to} to={l.to} end={l.end} className="pill" style={linkStyle}>
|
||||
{l.label}
|
||||
</NavLink>
|
||||
),
|
||||
)}
|
||||
{!loading && (
|
||||
<NavLink
|
||||
to={account.to}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createContext, useContext, useEffect, useState, useCallback, useMemo } from 'react'
|
||||
import { createContext, useContext, useEffect, useRef, useState, useCallback, useMemo } from 'react'
|
||||
import { api } from '../api/client.js'
|
||||
import { applyThemeTokens } from '../lib/themeVars.js'
|
||||
|
||||
const SiteContext = createContext(null)
|
||||
|
||||
@@ -7,11 +8,16 @@ const SiteContext = createContext(null)
|
||||
export function SiteProvider({ children }) {
|
||||
const [settings, setSettings] = useState({})
|
||||
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 () => {
|
||||
try {
|
||||
const data = await api.publicSettings()
|
||||
setSettings(data || {})
|
||||
setSettled(true)
|
||||
} catch {
|
||||
setSettings({})
|
||||
} finally {
|
||||
@@ -25,11 +31,38 @@ export function SiteProvider({ children }) {
|
||||
|
||||
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,
|
||||
// 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(() => {
|
||||
if (brand.accent) document.documentElement.style.setProperty('--accent', brand.accent)
|
||||
}, [brand.accent])
|
||||
}, [brand.accent, settings.theme])
|
||||
|
||||
// Memoized so consumers don't re-render on every provider render (brand is a
|
||||
// fresh object each render, which would otherwise churn the context value).
|
||||
|
||||
64
client/src/lib/adminNav.js
Normal file
64
client/src/lib/adminNav.js
Normal file
@@ -0,0 +1,64 @@
|
||||
// Who may see a row of the admin sidebar, and where that lets them go.
|
||||
//
|
||||
// Plain JS, in its own file, for two reasons. It is shared — AdminLayout renders
|
||||
// by it and Admin -> Navigation builds its palette by it (THEMING_AND_NAV.md
|
||||
// §8.1), and a second copy of this answer is exactly the thing this file exists
|
||||
// to abolish. And it is the closest thing in the client to an authorization
|
||||
// decision, so it belongs somewhere the test runner can reach, which a .jsx file
|
||||
// is not.
|
||||
//
|
||||
// **A row's own `roles` is the whole answer.** Until Phase 2 PR 8 this was
|
||||
// `roles` AND a hardcoded `MOD_PATHS` list of five paths that confined
|
||||
// moderators, AND a third prefix list in the redirect effect that disagreed with
|
||||
// both (docs/website/MODULE_SYSTEM.md §1.4). A module's rows could never be
|
||||
// added to a list core hardcodes, which is what forced the derivation — but the
|
||||
// lists had already drifted from each other without a module in sight.
|
||||
|
||||
/**
|
||||
* Can a viewer with this role see this row?
|
||||
*
|
||||
* Applied AFTER the override merge in both callers: an override is presentation
|
||||
* and this is the boundary, so an override saying `hidden: false` on a row this
|
||||
* role cannot see still shows nothing (THEMING_AND_NAV.md §7).
|
||||
*
|
||||
* A row with no `roles` is visible to everyone who reached the admin area at
|
||||
* all — that is the self-service case (Account, My Characters), and staff are a
|
||||
* superset of players.
|
||||
*/
|
||||
export function navItemVisibleTo(item, role) {
|
||||
return !item.roles || item.roles.includes(role)
|
||||
}
|
||||
|
||||
/**
|
||||
* The paths a viewer with this role may reach, derived from the rows they see.
|
||||
*
|
||||
* Takes the BASE nav, never the override-merged one: an override must not be
|
||||
* able to move this boundary in either direction. Hiding a row from a
|
||||
* moderator's sidebar must not also bar them from the page behind it, and
|
||||
* un-hiding one must not admit them to a page their role does not carry.
|
||||
*
|
||||
* @param {Array<{items: Array}>} baseNav the grouped admin nav
|
||||
* @param {string} role
|
||||
* @returns {Array<{to: string, exact: boolean}>}
|
||||
*/
|
||||
export function allowedPathsFor(baseNav, role) {
|
||||
return (Array.isArray(baseNav) ? baseNav : [])
|
||||
.flatMap((g) => g.items || [])
|
||||
.filter((item) => navItemVisibleTo(item, role))
|
||||
.map((item) => ({ to: item.to, exact: item.end === true }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this pathname one of them?
|
||||
*
|
||||
* A row carrying `end` matches exactly — `/admin` is the dashboard, not a prefix
|
||||
* of the whole admin area, and treating it as one would let every path through.
|
||||
* Every other row also covers its sub-routes, which is what keeps
|
||||
* `/admin/moderation/appeals/12` and a module's detail pages reachable without
|
||||
* anyone listing them.
|
||||
*/
|
||||
export function isAllowedPath(pathname, allowed) {
|
||||
return (allowed || []).some(({ to, exact }) =>
|
||||
exact ? pathname === to : pathname === to || pathname.startsWith(`${to}/`),
|
||||
)
|
||||
}
|
||||
505
client/src/lib/navOverrides.js
Normal file
505
client/src/lib/navOverrides.js
Normal file
@@ -0,0 +1,505 @@
|
||||
// Apply an admin's stored navigation overrides to a hardcoded NAV array.
|
||||
//
|
||||
// The three navs (public header, admin sidebar, player portal) stay declared in
|
||||
// code; this layer only reorders, relabels and hides what is already there.
|
||||
// See docs/website/THEMING_AND_NAV.md §7.
|
||||
//
|
||||
// **This is presentation, never authorization.** The override can carry
|
||||
// `label`, `order`, `hidden` and — admin nav only — `group`, and nothing else.
|
||||
// It cannot introduce a `to`, and it cannot touch `roles`, `feature`, `icon` or
|
||||
// `end`, so the existing role/feature filters in SiteHeader and AdminLayout run
|
||||
// *after* this merge, unchanged, and remain the actual boundary. An override
|
||||
// saying `hidden: false` on a role-gated item still shows nothing to a viewer
|
||||
// whose role check fails: hiding is subtractive here, never additive.
|
||||
//
|
||||
// Fail-safe throughout: anything unrecognized — an unknown `to`, a non-string
|
||||
// label, a group that does not exist — is ignored rather than rejected, so a
|
||||
// stale or hand-edited settings row degrades to the code default instead of
|
||||
// rendering a broken nav.
|
||||
|
||||
// Two shapes are supported, because two exist:
|
||||
// flat [{ to, label, ... }] — public header, player portal
|
||||
// grouped [{ title?, items: [{ to, label, ... }] }] — admin sidebar
|
||||
// Exported for modules/nav.js, which has to answer the same question about the
|
||||
// same array a moment earlier — one implementation, so the interleave and the
|
||||
// merge can never disagree about which shape they are looking at.
|
||||
export function isGrouped(nav) {
|
||||
return nav.length > 0 && nav.every((g) => g && Array.isArray(g.items))
|
||||
}
|
||||
|
||||
// A stored override entry is usable only field by field: a bad `label` must not
|
||||
// discard a good `order` alongside it.
|
||||
function cleanEntry(raw, groupTitles) {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
|
||||
const out = {}
|
||||
if (typeof raw.label === 'string' && raw.label.trim()) out.label = raw.label.trim()
|
||||
if (typeof raw.order === 'number' && Number.isFinite(raw.order)) out.order = raw.order
|
||||
if (raw.hidden === true) out.hidden = true
|
||||
// `group` may only name a section the base nav already declares. Anything else
|
||||
// — a renamed group, a typo, an invented category — is dropped, so an item can
|
||||
// never land in a header that does not exist.
|
||||
if (typeof raw.group === 'string' && groupTitles.has(raw.group)) out.group = raw.group
|
||||
return out
|
||||
}
|
||||
|
||||
// Sort by effective order, where an item the admin never reordered keeps its
|
||||
// index in the base array as its key. Two tie-breaks, in order: an explicit
|
||||
// order beats a coincidental index (the admin said "first", so first), and two
|
||||
// explicit orders stay in code order (the sort is stable).
|
||||
//
|
||||
// In practice the editor writes an order for every item in a list, the way
|
||||
// drag-and-drop reordering does, so ties are the stale-row case rather than the
|
||||
// normal one. They still have to resolve predictably.
|
||||
function byOrder(items) {
|
||||
return items
|
||||
.map((item, index) => ({ item, key: item.__order ?? index, explicit: item.__order !== undefined }))
|
||||
.sort((a, b) => a.key - b.key || Number(b.explicit) - Number(a.explicit))
|
||||
.map(({ item }) => {
|
||||
const { __order, ...rest } = item
|
||||
return rest
|
||||
})
|
||||
}
|
||||
|
||||
// Apply label/hidden/order to one flat list, with the sort key parked on
|
||||
// `__order` for byOrder to consume.
|
||||
//
|
||||
// `keepHidden` is what the admin editor needs and the site must not have: the
|
||||
// editor has to render a hidden row in its right place so it can be un-hidden,
|
||||
// while a layout must simply not render it. Same merge either way, so the two
|
||||
// can never disagree about where an item sits.
|
||||
function mergeItems(items, entries, keepHidden = false) {
|
||||
const out = []
|
||||
for (const item of items) {
|
||||
const o = entries.get(item.to)
|
||||
if (o?.hidden && !keepHidden) continue
|
||||
// Spread the base item first so `to`, `roles`, `feature`, `icon` and `end`
|
||||
// survive verbatim — the override only ever lands on `label`.
|
||||
out.push({
|
||||
...item,
|
||||
...(o?.label ? { label: o.label } : {}),
|
||||
...(keepHidden ? { defaultLabel: item.label, hidden: o?.hidden === true } : {}),
|
||||
__order: o?.order,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The stored overrides, cleaned and keyed, plus the group titles the base nav
|
||||
// declares. Shared by the merge and the editor so both read a row the same way.
|
||||
function readOverrides(baseNav, overrides, grouped) {
|
||||
const groupTitles = new Set(
|
||||
grouped ? baseNav.map((g) => g.title).filter((t) => typeof t === 'string') : [],
|
||||
)
|
||||
const entries = new Map()
|
||||
if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) return { entries, groupTitles }
|
||||
// Keyed by `to`, and only for a `to` the base nav actually declares. An
|
||||
// override for a route that no longer exists is dropped here, so deleting a
|
||||
// route in code can never leave a dangling override that does something
|
||||
// unexpected later.
|
||||
const known = new Set(
|
||||
grouped ? baseNav.flatMap((g) => g.items.map((i) => i.to)) : baseNav.map((i) => i.to),
|
||||
)
|
||||
for (const [to, raw] of Object.entries(overrides)) {
|
||||
if (!known.has(to)) continue
|
||||
const entry = cleanEntry(raw, groupTitles)
|
||||
if (entry && Object.keys(entry).length > 0) entries.set(to, entry)
|
||||
}
|
||||
return { entries, groupTitles }
|
||||
}
|
||||
|
||||
// Move items whose override names a different existing section. Groups keep
|
||||
// their coded order — only membership and within-group order move.
|
||||
function regroup(baseNav, entries) {
|
||||
const moved = new Map() // destination title → items pulled in from elsewhere
|
||||
const kept = baseNav.map((g) => {
|
||||
const items = []
|
||||
for (const item of g.items) {
|
||||
const o = entries.get(item.to)
|
||||
if (o?.group && o.group !== g.title) {
|
||||
if (!moved.has(o.group)) moved.set(o.group, [])
|
||||
moved.get(o.group).push(item)
|
||||
continue
|
||||
}
|
||||
items.push(item)
|
||||
}
|
||||
return { ...g, items }
|
||||
})
|
||||
return { kept, moved }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array} baseNav the hardcoded nav — the source of truth for `to`,
|
||||
* `roles`, `feature`, `icon` and `end`
|
||||
* @param {object|null} overrides the parsed settings JSON, keyed by `to`, or
|
||||
* null when the admin never touched this nav
|
||||
* @returns {Array} a new array of the same shape, or `baseNav` itself when there
|
||||
* is nothing to apply
|
||||
*/
|
||||
export function applyNavOverrides(baseNav, overrides) {
|
||||
if (!Array.isArray(baseNav)) return []
|
||||
// The untouched path, and the one that matters most: no row, a malformed row,
|
||||
// or a row with nothing usable in it all render the nav exactly as coded.
|
||||
if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) return baseNav
|
||||
|
||||
const grouped = isGrouped(baseNav)
|
||||
const { entries } = readOverrides(baseNav, overrides, grouped)
|
||||
if (entries.size === 0) return baseNav
|
||||
|
||||
if (!grouped) return byOrder(mergeItems(baseNav, entries))
|
||||
|
||||
// Grouped: an item may also be moved into another *existing* titled section.
|
||||
const { kept, moved } = regroup(baseNav, entries)
|
||||
|
||||
return kept
|
||||
.map((g) => ({
|
||||
...g,
|
||||
items: byOrder(mergeItems([...g.items, ...(moved.get(g.title) || [])], entries)),
|
||||
}))
|
||||
// A group whose every item was hidden must not leave an orphaned header.
|
||||
// AdminLayout drops empty groups again after its own role filter; doing it
|
||||
// here too keeps the util correct on its own.
|
||||
.filter((g) => g.items.length > 0)
|
||||
}
|
||||
|
||||
// ── The admin editor's round trip ────────────────────────────────────────
|
||||
//
|
||||
// Two functions, inverse to each other, kept in this file rather than beside the
|
||||
// editor screen so the thing that *writes* an override and the thing that
|
||||
// *applies* one can never drift: the rows the admin drags are produced by the
|
||||
// same merge the site renders, hidden ones included.
|
||||
|
||||
/**
|
||||
* The base nav plus its stored overrides, as editable rows — always in the
|
||||
* grouped shape, so one editor handles both navs.
|
||||
*
|
||||
* Unlike applyNavOverrides this keeps hidden rows (marked `hidden: true`, so
|
||||
* they can be un-hidden) and keeps empty groups (so something can be moved back
|
||||
* into one). Each row carries `defaultLabel`, which is what "reset this label"
|
||||
* restores and what the input shows as its placeholder.
|
||||
*
|
||||
* @param {Array} baseNav the hardcoded nav, flat or grouped
|
||||
* @param {object|null} overrides the parsed settings JSON
|
||||
* @returns {Array<{title: string|null, items: Array}>}
|
||||
*/
|
||||
export function buildNavRows(baseNav, overrides) {
|
||||
if (!Array.isArray(baseNav) || baseNav.length === 0) return []
|
||||
const grouped = isGrouped(baseNav)
|
||||
const { entries } = readOverrides(baseNav, overrides, grouped)
|
||||
|
||||
if (!grouped) {
|
||||
return [{ title: null, items: byOrder(mergeItems(baseNav, entries, true)) }]
|
||||
}
|
||||
const { kept, moved } = regroup(baseNav, entries)
|
||||
return kept.map((g) => ({
|
||||
...g,
|
||||
title: g.title ?? null,
|
||||
items: byOrder(mergeItems([...g.items, ...(moved.get(g.title) || [])], entries, true)),
|
||||
}))
|
||||
}
|
||||
|
||||
// Did the admin actually move anything? Comparing the edited sequence with the
|
||||
// coded one is what decides whether orders are written at all: an admin who only
|
||||
// renamed an item should not pin the position of every other one, or a route
|
||||
// added in code later would land in an arbitrary place.
|
||||
//
|
||||
// The base side is restricted to the rows the editor is actually holding: §8.1
|
||||
// filters the palette to what this admin can themselves see, and an item that
|
||||
// their role or a 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
|
||||
32
client/src/lib/settingsJson.js
Normal file
32
client/src/lib/settingsJson.js
Normal 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
|
||||
47
client/src/lib/themeVars.js
Normal file
47
client/src/lib/themeVars.js
Normal 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
|
||||
}
|
||||
56
client/src/lib/useNavOverrides.js
Normal file
56
client/src/lib/useNavOverrides.js
Normal 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
|
||||
@@ -56,3 +56,15 @@ export function useShardFeatures() {
|
||||
export function canSee(features, name) {
|
||||
return !features || features.set.has(name)
|
||||
}
|
||||
|
||||
// The same answer in the shape core's generic feature seam takes: a Set-like of
|
||||
// the flags this viewer may see, or null while we do not know yet
|
||||
// (modules/featureGate.js). Core registers THIS as the provider for the `uo`
|
||||
// namespace (main.jsx), so the ten shard-gated rows in the public header are
|
||||
// already resolved through the module seam rather than beside it — when Phase 3
|
||||
// moves those rows into the module, the registration moves with this file and
|
||||
// core is left with nothing to delete.
|
||||
export function useShardFlags() {
|
||||
const features = useShardFeatures()
|
||||
return features ? features.set : null
|
||||
}
|
||||
|
||||
@@ -2,12 +2,69 @@ import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App.jsx'
|
||||
import { publishSharedDependencies } from './modules/shared.js'
|
||||
import { registerFeatureProvider } from './modules/registry.js'
|
||||
import { useShardFlags } from './lib/useShardFeatures.js'
|
||||
import './styles/theme.css'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
// Publish window.__rg BEFORE rendering and before any module chunk evaluates.
|
||||
// Installed modules arrive as `<script type="module" src="/modules/<id>/…">`
|
||||
// tags the server injects into the shell (server/src/utils/htmlShell.js), placed
|
||||
// after this bundle's own tag; module scripts execute in document order, so they
|
||||
// resolve their externals against the global this call sets up
|
||||
// (docs/website/MODULE_API.md §3.2).
|
||||
publishSharedDependencies()
|
||||
|
||||
// Core registers through the same seam a module uses, and registers FIRST — the
|
||||
// client twin of the server's `registries.registerCore()` (MODULE_SYSTEM.md
|
||||
// §1.9). The ten shard-gated rows in the public header are core's only because
|
||||
// Phase 3 has not moved them yet; routing them through the registry now means
|
||||
// SiteHeader holds one mechanism instead of two, and the extraction becomes a
|
||||
// deletion rather than a rewrite made under extraction pressure.
|
||||
//
|
||||
// The owner id is `core`, which is what a nav row with no `moduleId` resolves
|
||||
// against (modules/featureGate.js). The namespace is `uo`, so a module that
|
||||
// wants to read these flags — the `uo` module itself, once it owns them — asks
|
||||
// for them by the name they will always have had.
|
||||
registerFeatureProvider('core', 'uo', useShardFlags)
|
||||
|
||||
// Render on DOMContentLoaded rather than immediately, and that is the one line
|
||||
// of core's boot the module system changes.
|
||||
//
|
||||
// Deferred scripts — which every `type="module"` script is — execute in document
|
||||
// order and ALL of them finish before DOMContentLoaded fires. Waiting for that
|
||||
// event is therefore the guarantee that every installed module has registered
|
||||
// its routes before React reads the registry: no loading state, no re-render,
|
||||
// and no ordering race between core's bundle and a module's. A module chunk that
|
||||
// 404s or throws does not hold the event back, so a broken module costs its own
|
||||
// pages and not the site.
|
||||
//
|
||||
// The readyState check below is `'complete'`, and it is not the obvious
|
||||
// `'loading'`. A DEFERRED script — which every `type="module"` script is — runs
|
||||
// after the document has been parsed, so by the time this line executes
|
||||
// readyState is already `'interactive'`; DOMContentLoaded has NOT fired yet and
|
||||
// still comes after every deferred script. Testing for `'loading'` therefore
|
||||
// mounts immediately, before any module chunk has evaluated, and a module's
|
||||
// routes are missing from the very first render — which looks exactly like a
|
||||
// module that failed to load: its URL falls through to core's catch-all and
|
||||
// redirects home. Found by loading a real chunk in a browser; no unit test in
|
||||
// this repo can see it.
|
||||
//
|
||||
// `'complete'` is only reached after `load`, which is strictly later than any
|
||||
// static deferred script, so this branch is the genuine "the event has already
|
||||
// been and gone" case and not a wrong guess about our own timing.
|
||||
function mount() {
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
}
|
||||
|
||||
if (document.readyState === 'complete') {
|
||||
mount()
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', mount, { once: true })
|
||||
}
|
||||
|
||||
56
client/src/modules/featureGate.js
Normal file
56
client/src/modules/featureGate.js
Normal file
@@ -0,0 +1,56 @@
|
||||
// Which nav rows a viewer may see, when the answer belongs to a module.
|
||||
//
|
||||
// Phase 2, PR 8 of docs/website/MODULE_SYSTEM.md §2.7 (§1.5 states the problem);
|
||||
// the contract is docs/website/MODULE_API.md §3.3.
|
||||
//
|
||||
// Ten of the sixteen rows in the public header carry a `feature`, and every one
|
||||
// of them is a shard surface an admin can disable or gate to a higher audience.
|
||||
// The provider that answers those questions — `useShardFeatures` — moves out
|
||||
// with the module, so core cannot keep calling it directly and still be a core.
|
||||
// It keeps a generic seam instead, and the module fills it.
|
||||
//
|
||||
// **The namespace comes from the registration, not from the string.** A row's
|
||||
// `feature` is resolved by the provider its OWN module registered, so a module
|
||||
// author writes `feature: 'status'` exactly as it reads today: nothing parses a
|
||||
// prefix, and a typo'd namespace is not a thing that can exist. Core's own rows
|
||||
// carry no `moduleId` and resolve against the owner id `core`, which is what
|
||||
// core registers `useShardFeatures` under until Phase 3 moves those rows into
|
||||
// the module and they arrive stamped `uo` instead.
|
||||
//
|
||||
// Everything here fails OPEN, and that is deliberate and unchanged from
|
||||
// useShardFeatures' own posture: this is presentation, the gate is server-side
|
||||
// (a disabled feature 404s and an out-of-rung one 403s whether or not a link was
|
||||
// rendered), so an unknown answer shows the link rather than blanking the nav.
|
||||
// The one thing a UI mistake must never do here is hide a page from someone
|
||||
// entitled to it.
|
||||
|
||||
/**
|
||||
* The predicate the layouts filter their nav with.
|
||||
*
|
||||
* @param {Map<string, {has: (name: string) => boolean} | null | undefined>} flagsByOwner
|
||||
* one entry per registered provider, keyed by the id of the module that
|
||||
* registered it. The value is whatever that provider's hook returned this
|
||||
* render: a Set-like of the flags this viewer may see, or `null` while the
|
||||
* answer is still in flight.
|
||||
* @returns {(item: object) => boolean}
|
||||
*/
|
||||
export function buildFeatureGate(flagsByOwner) {
|
||||
return function isVisible(item) {
|
||||
if (!item || !item.feature) return true
|
||||
const owner = item.moduleId ?? 'core'
|
||||
// No provider for this owner: the row names a flag nothing answers for. That
|
||||
// is the no-module-installed case — no core row carries a `feature` once the
|
||||
// module is out — and it is a correct no-op rather than a hidden row.
|
||||
if (!flagsByOwner || !flagsByOwner.has(owner)) return true
|
||||
const flags = flagsByOwner.get(owner)
|
||||
// Still loading, or a provider that returned something unusable. Both are
|
||||
// "we do not know yet", and both show the link.
|
||||
if (!flags || typeof flags.has !== 'function') return true
|
||||
return flags.has(item.feature)
|
||||
}
|
||||
}
|
||||
|
||||
/** The gate an area with no providers gets: everything is visible. */
|
||||
export const OPEN_GATE = () => true
|
||||
|
||||
export default buildFeatureGate
|
||||
65
client/src/modules/features.jsx
Normal file
65
client/src/modules/features.jsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { createContext, useContext, useMemo, useState } from 'react'
|
||||
import { featureProviders } from './registry.js'
|
||||
import { buildFeatureGate, OPEN_GATE } from './featureGate.js'
|
||||
|
||||
// The React half of the feature seam. The decision logic is featureGate.js,
|
||||
// which is plain JS and therefore testable in a runner with no DOM; this file is
|
||||
// wiring, the same split registry.js and shared.js already use.
|
||||
//
|
||||
// **Calling a hook per provider inside a loop is the point, and it is legal
|
||||
// here.** The rules of hooks require the same hooks in the same order on every
|
||||
// render of a component — not a statically known list. The provider list is
|
||||
// fixed before the first render (registration happens while module chunks
|
||||
// evaluate, and main.jsx does not mount until DOMContentLoaded), there is no
|
||||
// unregistering, and the snapshot below freezes it per component instance
|
||||
// anyway. So the loop's length cannot change between renders of this provider,
|
||||
// which is the actual requirement.
|
||||
//
|
||||
// A provider hook returns a Set-like of the flags this viewer may see, or `null`
|
||||
// while it is still fetching. Core knows nothing else about it: what a flag
|
||||
// means, how it is fetched, and what it is gated on are all the module's.
|
||||
|
||||
const FeatureGateContext = createContext(OPEN_GATE)
|
||||
|
||||
export function ModuleFeaturesProvider({ children }) {
|
||||
// Snapshotted once. useState's initialiser runs on the first render only, so
|
||||
// even a provider that somehow registered late cannot change this instance's
|
||||
// hook count mid-life — it would be ignored until the next mount, which is a
|
||||
// far better failure than a crashed render.
|
||||
const [providers] = useState(featureProviders)
|
||||
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks -- fixed-length list, see above
|
||||
const values = providers.map((provider) => provider.hook())
|
||||
|
||||
const gate = useMemo(
|
||||
() => {
|
||||
const byOwner = new Map()
|
||||
// First registration wins for a given owner: a module that registers two
|
||||
// namespaces answers its own nav rows from the first, rather than from
|
||||
// whichever happened to be stored last.
|
||||
providers.forEach((provider, i) => {
|
||||
if (!byOwner.has(provider.id)) byOwner.set(provider.id, values[i])
|
||||
})
|
||||
return buildFeatureGate(byOwner)
|
||||
},
|
||||
// One dependency per provider — a fixed-length list, for the same reason the
|
||||
// hook loop above is fixed-length.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[providers, ...values],
|
||||
)
|
||||
|
||||
return <FeatureGateContext.Provider value={gate}>{children}</FeatureGateContext.Provider>
|
||||
}
|
||||
|
||||
/**
|
||||
* The predicate to filter nav rows with: `(item) => boolean`, true when the row
|
||||
* carries no `feature` or when its module says this viewer may see it.
|
||||
*
|
||||
* Outside a provider it is the open gate, so a component rendered in isolation
|
||||
* (a test, a preview) shows its whole nav rather than none of it.
|
||||
*/
|
||||
export function useFeatureGate() {
|
||||
return useContext(FeatureGateContext)
|
||||
}
|
||||
|
||||
export default ModuleFeaturesProvider
|
||||
174
client/src/modules/nav.js
Normal file
174
client/src/modules/nav.js
Normal file
@@ -0,0 +1,174 @@
|
||||
// The interleave of module nav items into core's nav.
|
||||
//
|
||||
// Phase 2, PR 8 of docs/website/MODULE_SYSTEM.md §2.7 (§1.4 states the problem);
|
||||
// the normative contract is docs/website/MODULE_API.md §3.3.
|
||||
//
|
||||
// **Module items join the BASE array, before anything else happens to it.** That
|
||||
// is the whole design of this file and the override merge next door forces it:
|
||||
// `applyNavOverrides` / `buildPublicNav` are keyed by `to` and drop any key the
|
||||
// base array does not declare (lib/navOverrides.js — deliberately, so a deleted
|
||||
// route cannot leave a stale row doing something unexpected later). Append
|
||||
// module items *after* that merge and they are unreachable to Admin →
|
||||
// Navigation: unorderable, unrelabellable, unhideable. Today's UO rows are all
|
||||
// three of those things, so appending would make the extraction a visible
|
||||
// regression for every operator who has ever touched their nav.
|
||||
//
|
||||
// So the pipeline gains one step at the front and nothing else changes:
|
||||
//
|
||||
// withModuleNav(NAV, area) → admin overrides → role/feature filter → rendered
|
||||
//
|
||||
// and the filter stays last, which is what keeps it the boundary an override
|
||||
// cannot cross (THEMING_AND_NAV.md §7). MODULE_API.md §3.3 wrote those last two
|
||||
// the other way round; the code is right and the contract was amended.
|
||||
//
|
||||
// The result is that a module row is, to everything downstream, an ordinary row.
|
||||
// Nothing in navOverrides.js, NavEditor.jsx or the layouts knows a module exists.
|
||||
|
||||
import { navFor } from './registry.js'
|
||||
import { isGrouped } from '../lib/navOverrides.js'
|
||||
|
||||
// Rows with no group of their own are collected under this key. A Symbol rather
|
||||
// than a string so it cannot collide with a group an admin or a module names.
|
||||
const UNGROUPED = Symbol('ungrouped')
|
||||
|
||||
/**
|
||||
* Sort by effective position, where a row that asked for nothing keeps the index
|
||||
* it already had. Three tie-breaks, in this order: an explicit `order` beats a
|
||||
* coincidental index (the module said "third", so third), and two explicit
|
||||
* orders keep registration order, which `navFor` has already put in scan order.
|
||||
*
|
||||
* The same rule byOrder/place use in lib/navOverrides.js, and it has to be — an
|
||||
* admin who then drags that row is editing the position this produced.
|
||||
*/
|
||||
function place(entries) {
|
||||
return entries
|
||||
.map((entry, index) => ({ ...entry, index }))
|
||||
.sort((a, b) => a.key - b.key || Number(b.explicit) - Number(a.explicit) || a.index - b.index)
|
||||
.map(({ item }) => item)
|
||||
}
|
||||
|
||||
function entryFor(item, fallbackKey) {
|
||||
return { item, key: item.order ?? fallbackKey, explicit: item.order !== undefined }
|
||||
}
|
||||
|
||||
function coreEntries(items) {
|
||||
return items.map((item, index) => ({ item, key: index, explicit: false }))
|
||||
}
|
||||
|
||||
/** The `to`s a base nav already claims, flat or grouped. */
|
||||
function claimedPaths(baseNav, grouped) {
|
||||
return new Set(grouped ? baseNav.flatMap((g) => g.items.map((i) => i.to)) : baseNav.map((i) => i.to))
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a module row whose `to` is already on the nav, and say so.
|
||||
*
|
||||
* Not a policy about where a module may link — it is that `to` is the KEY the
|
||||
* override layer stores under and React renders by. Two rows sharing one would
|
||||
* give an admin a single editor row that silently moves both, and a duplicate
|
||||
* key in the rendered list. Dropping the newcomer keeps core's row, which is the
|
||||
* one any existing override was written against.
|
||||
*
|
||||
* Fail-safe like every other read in this area: the offending row goes, its
|
||||
* neighbours stay.
|
||||
*/
|
||||
function withoutCollisions(items, claimed) {
|
||||
const out = []
|
||||
for (const item of items) {
|
||||
if (!item || typeof item.to !== 'string' || !item.to) continue
|
||||
if (claimed.has(item.to)) {
|
||||
console.warn(
|
||||
`[modules] nav item "${item.to}" from module "${item.moduleId}" collides with an existing row and was dropped`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
claimed.add(item.to)
|
||||
out.push(item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The flat navs — the public header and the player portal.
|
||||
//
|
||||
// No groups, so `order` is a position in the one list: core rows are keyed by
|
||||
// their index and a module row by the `order` it asked for. A module row with no
|
||||
// order appends after the coded ones, in registration order, rather than jumping
|
||||
// to the front on a 0 default — the same choice buildPublicNav makes for an
|
||||
// admin-created link.
|
||||
function mergeFlat(baseNav, items) {
|
||||
return place([...coreEntries(baseNav), ...items.map((item, i) => entryFor(item, baseNav.length + i))])
|
||||
}
|
||||
|
||||
// The grouped nav — the admin sidebar.
|
||||
//
|
||||
// `group` names an existing core group and the row lands inside it: Moderation
|
||||
// and System, where today's UO rows already sit (§1.4). An unknown group name
|
||||
// creates a group at the end rather than dropping the row — a typo must cost a
|
||||
// position, never a link. A row with no `group` at all lands in a trailing
|
||||
// untitled group, which renders as ungrouped links; core does not invent a
|
||||
// display title out of a module id.
|
||||
//
|
||||
// An ungrouped row is NOT folded into one of core's own untitled groups
|
||||
// (Dashboard's, Account's): those are furniture pinned to the top and bottom of
|
||||
// the sidebar, and a module page does not belong beside "Account".
|
||||
//
|
||||
// A group created here is a group as far as everything downstream is concerned,
|
||||
// including as a destination in Admin → Navigation's "move to section" control:
|
||||
// `readOverrides` builds its set of legal destinations from the base nav it is
|
||||
// handed, which is this one.
|
||||
function mergeGrouped(baseNav, items) {
|
||||
const titles = new Set(baseNav.map((g) => g.title).filter((t) => typeof t === 'string'))
|
||||
const into = new Map() // existing group title → rows
|
||||
const fresh = new Map() // new group title (or UNGROUPED) → rows, first-seen order
|
||||
|
||||
for (const item of items) {
|
||||
const named = typeof item.group === 'string' && item.group ? item.group : null
|
||||
const key = named ?? UNGROUPED
|
||||
const bucket = named !== null && titles.has(named) ? into : fresh
|
||||
if (!bucket.has(key)) bucket.set(key, [])
|
||||
bucket.get(key).push(item)
|
||||
}
|
||||
|
||||
const kept = baseNav.map((g) => {
|
||||
const incoming = into.get(g.title)
|
||||
if (!incoming) return g
|
||||
return {
|
||||
...g,
|
||||
items: place([...coreEntries(g.items), ...incoming.map((item, i) => entryFor(item, g.items.length + i))]),
|
||||
}
|
||||
})
|
||||
|
||||
const created = [...fresh.entries()].map(([key, rows]) => {
|
||||
const items_ = place(rows.map((item, i) => entryFor(item, i)))
|
||||
return key === UNGROUPED ? { items: items_ } : { title: key, items: items_ }
|
||||
})
|
||||
|
||||
return [...kept, ...created]
|
||||
}
|
||||
|
||||
/**
|
||||
* The base nav a layout should render: core's coded array with every installed
|
||||
* module's rows for this area interleaved into it.
|
||||
*
|
||||
* Returns `baseNav` ITSELF when no module registered anything for this area, so
|
||||
* an instance with no modules installed renders the identical array it renders
|
||||
* today — the same "untouched path" guarantee applyNavOverrides makes, and what
|
||||
* makes a `useMemo` with an empty dependency list around this call honest.
|
||||
*
|
||||
* Safe to call once per component and cache: registration completes before the
|
||||
* first render (main.jsx waits for DOMContentLoaded — MODULE_API.md §3.1) and
|
||||
* there is no unregistering, so this answer cannot change during a session.
|
||||
*
|
||||
* @param {Array} baseNav the coded NAV, flat or grouped
|
||||
* @param {'public'|'admin'|'player'} area
|
||||
* @returns {Array} a nav of the same shape
|
||||
*/
|
||||
export function withModuleNav(baseNav, area) {
|
||||
if (!Array.isArray(baseNav)) return []
|
||||
const grouped = isGrouped(baseNav)
|
||||
const items = withoutCollisions(navFor(area), claimedPaths(baseNav, grouped))
|
||||
if (items.length === 0) return baseNav
|
||||
return grouped ? mergeGrouped(baseNav, items) : mergeFlat(baseNav, items)
|
||||
}
|
||||
|
||||
export default withModuleNav
|
||||
149
client/src/modules/registry.js
Normal file
149
client/src/modules/registry.js
Normal file
@@ -0,0 +1,149 @@
|
||||
// ── The client-side module registry ────────────────────────────────────────
|
||||
//
|
||||
// Phase 2, PR 7 of docs/website/MODULE_SYSTEM.md §2.7. The normative contract is
|
||||
// docs/website/MODULE_API.md §3.3; where the two disagree, the contract wins.
|
||||
//
|
||||
// A module's prebuilt chunk registers its routes, its nav entries and its feature
|
||||
// provider here, and core reads them back. This is the client twin of the
|
||||
// server's modules/loader.js — with one structural difference worth stating,
|
||||
// because it is what makes the file this short: core *hands* the registry to the
|
||||
// module (on `window.__rg`, see shared.js) rather than discovering it. There is
|
||||
// nothing to scan, nothing to validate a manifest against, and no failure mode
|
||||
// where half a module is registered.
|
||||
//
|
||||
// **Timing is the whole design.** Module chunks are `<script type="module" src>`
|
||||
// tags the server injects before `</body>` (server/src/utils/htmlShell.js), after
|
||||
// core's own bundle. Module scripts are deferred, so they evaluate after that
|
||||
// bundle has run — which is where `window.__rg` is published — and all of them
|
||||
// finish before DOMContentLoaded. main.jsx waits for that same event before
|
||||
// calling render(), so registration is complete before React reads any of this.
|
||||
//
|
||||
// That is what buys the simplicity here: registration is a plain synchronous
|
||||
// write with no subscribers, not an observable store, because nothing can
|
||||
// register after the first render. If that ever stops being true it changes in
|
||||
// this file and in main.jsx, not in a dozen consumers.
|
||||
//
|
||||
// What PR 7 wires up is `routesFor` (App.jsx). `navFor` and `featureProviderFor`
|
||||
// are stored and returned faithfully but core does not read them yet — PR 8 adds
|
||||
// the nav interleave and the feature-provider seam. Storing them is not the kind
|
||||
// of accepting stub the server's registries refused to be: nothing is discarded
|
||||
// here, so a module that registers nav in this core gets it back from `navFor`.
|
||||
|
||||
const routes = { public: [], admin: [], player: [] }
|
||||
const nav = { public: [], admin: [], player: [] }
|
||||
const providers = new Map()
|
||||
const registered = new Set()
|
||||
|
||||
const AREAS = ['public', 'admin', 'player']
|
||||
|
||||
function assertArea(area, call) {
|
||||
if (!AREAS.includes(area)) throw new Error(`${call}: unknown area "${area}"`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Route components, by area.
|
||||
*
|
||||
* @param {string} id the module id — the URL segment its routes are namespaced under
|
||||
* @param {{public?: Array, admin?: Array, player?: Array}} byArea
|
||||
* each entry `{ path, element, gate? }`. `path` is relative to the module's
|
||||
* namespace; core prefixes it and mounts it inside the area's existing wrapper
|
||||
* (`/<id>/…` under MaintenanceGate, `/admin/<id>/…` under RequireAuth +
|
||||
* AdminLayout, `/player/<id>/…` under RequirePlayer + PlayerPortalLayout).
|
||||
* `gate` is an optional `{ roles: [...] }` that core applies as its own
|
||||
* RoleGate — a module cannot supply an auth wrapper, because the sidebar and
|
||||
* the route table have to agree about who may see what (§3.3).
|
||||
*/
|
||||
export function registerRoutes(id, byArea) {
|
||||
for (const [area, list] of Object.entries(byArea || {})) {
|
||||
assertArea(area, 'registerRoutes')
|
||||
for (const route of list || []) {
|
||||
// Prefixed HERE rather than by the module: a module cannot claim a path
|
||||
// outside its own namespace however it spells `path` — a leading `/`, a
|
||||
// trailing one, or several — because it never gets to write the segment
|
||||
// its routes hang under.
|
||||
const path = `${id}/${String(route.path || '').replace(/^\/+/, '')}`.replace(/\/+$/, '')
|
||||
routes[area].push({ ...route, path, moduleId: id })
|
||||
}
|
||||
}
|
||||
registered.add(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Nav entries, interleaved into CORE groups rather than appended as a block.
|
||||
*
|
||||
* Today's UO items sit inside core's own Moderation and System groups; a "UO"
|
||||
* group at the bottom of the sidebar would be a visible regression on the day
|
||||
* the module is extracted (MODULE_SYSTEM.md §1.4). `group` names an existing
|
||||
* core group, `order` sorts within it, and an unknown group name appends rather
|
||||
* than dropping the item — a mis-typed group must cost a position, never a link.
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {{area: string, items: Array<{label, to, group?, order?, roles?, feature?}>}} spec
|
||||
*/
|
||||
export function registerNav(id, spec) {
|
||||
const { area, items } = spec || {}
|
||||
assertArea(area, 'registerNav')
|
||||
for (const item of items || []) nav[area].push({ ...item, moduleId: id })
|
||||
registered.add(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* The hook that answers "which of this module's features may this viewer see".
|
||||
*
|
||||
* Core keeps a generic flag context and owns none of the semantics — `uo` fills
|
||||
* its namespace with today's `useShardFeatures` (MODULE_SYSTEM.md §1.5). With no
|
||||
* module installed the nav filter is a correct no-op, because no core nav item
|
||||
* carries a `feature` today.
|
||||
*/
|
||||
export function registerFeatureProvider(id, namespace, hook) {
|
||||
providers.set(namespace, { id, hook })
|
||||
registered.add(id)
|
||||
}
|
||||
|
||||
export const routesFor = (area) => routes[area] || []
|
||||
|
||||
// Sorted by the `order` a module asked for. Array#sort is stable in every engine
|
||||
// this ships to, so two modules asking for the same slot keep load order —
|
||||
// which is alphabetical by id, the same order the server scans in (§4.2).
|
||||
export const navFor = (area) =>
|
||||
[...(nav[area] || [])].sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
|
||||
|
||||
export const featureProviderFor = (namespace) => providers.get(namespace)
|
||||
|
||||
/**
|
||||
* Every registered provider, for core's feature context to call.
|
||||
*
|
||||
* Exported from the module but deliberately NOT a member of the `registry`
|
||||
* object below: a module asks for a namespace it knows the name of, and has no
|
||||
* business enumerating what everyone else registered. Core needs the list
|
||||
* because it has to call each hook — unconditionally, in a fixed order, at the
|
||||
* top of a component (modules/features.jsx).
|
||||
*/
|
||||
export const featureProviders = () =>
|
||||
[...providers.entries()].map(([namespace, { id, hook }]) => ({ id, namespace, hook }))
|
||||
|
||||
export const registeredIds = () => [...registered]
|
||||
|
||||
/** Test seam. Nothing in the app calls this — there is no unregistering. */
|
||||
export function _reset() {
|
||||
for (const area of AREAS) {
|
||||
routes[area].length = 0
|
||||
nav[area].length = 0
|
||||
}
|
||||
providers.clear()
|
||||
registered.clear()
|
||||
}
|
||||
|
||||
// The object handed to modules on window.__rg.registry. Deliberately the write
|
||||
// calls plus the read ones: a module reading `routesFor` is how it finds out
|
||||
// another module is installed, which is the only supported form of module-to-
|
||||
// module awareness (there is no dependency resolution).
|
||||
export const registry = {
|
||||
registerRoutes,
|
||||
registerNav,
|
||||
registerFeatureProvider,
|
||||
routesFor,
|
||||
navFor,
|
||||
featureProviderFor,
|
||||
registeredIds,
|
||||
}
|
||||
96
client/src/modules/shared.js
Normal file
96
client/src/modules/shared.js
Normal file
@@ -0,0 +1,96 @@
|
||||
// ── window.__rg — the shared-dependency global ─────────────────────────────
|
||||
//
|
||||
// Phase 2, PR 7 of docs/website/MODULE_SYSTEM.md §2.7; the normative shape is
|
||||
// docs/website/MODULE_API.md §3.2.
|
||||
//
|
||||
// A module's client half is a PREBUILT ESM chunk — the operator never builds
|
||||
// anything (MODULE_SYSTEM.md §1.14) — served same-origin and loaded under
|
||||
// `script-src 'self'` with no 'unsafe-inline'. That combination is what rules out
|
||||
// an import map: an import map has to be an inline `<script type="importmap">`,
|
||||
// and the policy forbids inline scripts outright. So the shared dependencies ride
|
||||
// on a global, and the module's Rollup externals are aliased to two-line shims
|
||||
// that re-export from it (§3.6).
|
||||
//
|
||||
// **There is exactly one React in the page and core owns it.** A module that
|
||||
// bundled its own would get a second hook dispatcher and fail at its first
|
||||
// useState. That is the same rule the server half enforces for `express` and
|
||||
// `express-validator` on `ctx`, and for the same reason: anything shared between
|
||||
// core and a module is owned by core and HANDED OVER, never resolved by the
|
||||
// module.
|
||||
|
||||
import * as react from 'react'
|
||||
import * as reactDom from 'react-dom/client'
|
||||
import * as router from 'react-router-dom'
|
||||
// The automatic JSX runtime, and it is not decoration. A module's bundler
|
||||
// compiles every .jsx file to imports from `react/jsx-runtime` under the modern
|
||||
// default, and those have to resolve to CORE's React like every other import.
|
||||
// Without it here a module would have to build with `jsxRuntime: 'classic'`;
|
||||
// with it, a module uses the default its tooling already assumes.
|
||||
import * as jsxRuntime from 'react/jsx-runtime'
|
||||
|
||||
import { registry } from './registry.js'
|
||||
import { MODULE_API_VERSION } from './version.js'
|
||||
|
||||
import PublicLayout from '../components/PublicLayout.jsx'
|
||||
import PageHeader from '../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState, EmptyState } from '../components/PageState.jsx'
|
||||
import { useAsync } from '../lib/useAsync.js'
|
||||
import { useAuth } from '../contexts/AuthContext.jsx'
|
||||
import { useSite } from '../contexts/SiteContext.jsx'
|
||||
import { request, ApiError } from '../api/client.js'
|
||||
|
||||
// The UI kit is CURATED AND CLOSED (§3.4), not a re-export of components/. These
|
||||
// seven are what the smallest UO page already needs beyond React and the router:
|
||||
// without them a module either reaches into core's tree — violating the
|
||||
// zero-import rule the whole boundary rests on — or ships its own copies, which
|
||||
// means a module page that does not look like the site it is installed in, and
|
||||
// that drifts further every time core's layout changes.
|
||||
//
|
||||
// Adding a member is a MINOR MODULE_API_VERSION bump; changing a member's props
|
||||
// is a MAJOR one. That is a real constraint on core's own refactoring and it is
|
||||
// the price of the boundary being worth anything.
|
||||
//
|
||||
// `AdminPage` appears in §3.4's table and is deliberately absent: core has no
|
||||
// such component — admin views are plain markup inside AdminLayout — and
|
||||
// inventing one to satisfy a table would be a core change with no consumer until
|
||||
// Phase 3. The contract is amended rather than the code padded, and adding it
|
||||
// later costs a minor bump, which is exactly the case the versioning is for.
|
||||
const ui = {
|
||||
PublicLayout,
|
||||
PageHeader,
|
||||
Loading,
|
||||
ErrorState,
|
||||
EmptyState,
|
||||
useAsync,
|
||||
useAuth,
|
||||
useSite,
|
||||
}
|
||||
|
||||
// The request PRIMITIVE, not the `api` object (§3.5). `api.atlas` and `api.shard`
|
||||
// are module bindings that only still live in core's client because Phase 3 has
|
||||
// not moved them; a module builds its own namespace over `request` and owns the
|
||||
// paths it calls — which is right, because it owns the routes at the other end.
|
||||
const api = { request, ApiError }
|
||||
|
||||
/**
|
||||
* Publish `window.__rg`. Called by main.jsx before it renders, and before any
|
||||
* module chunk evaluates.
|
||||
*
|
||||
* Frozen, one level down as well as at the top: the object a module reaches for
|
||||
* its React is not somewhere a module gets to leave something for the next one.
|
||||
* Cross-module communication is a thing the contract does not have, and an
|
||||
* unfrozen global is how a codebase acquires one by accident.
|
||||
*/
|
||||
export function publishSharedDependencies() {
|
||||
window.__rg = Object.freeze({
|
||||
version: MODULE_API_VERSION,
|
||||
react,
|
||||
reactDom,
|
||||
router,
|
||||
jsxRuntime,
|
||||
registry,
|
||||
ui: Object.freeze(ui),
|
||||
api: Object.freeze(api),
|
||||
})
|
||||
return window.__rg
|
||||
}
|
||||
14
client/src/modules/version.js
Normal file
14
client/src/modules/version.js
Normal file
@@ -0,0 +1,14 @@
|
||||
// The client's copy of MODULE_API_VERSION. It must equal the server's
|
||||
// (server/src/modules/version.js) — the two halves version ONE contract
|
||||
// (docs/website/MODULE_API.md §1.1), and a module checks whichever half it is
|
||||
// talking to: `coreApi` against the server's at load time, `window.__rg.version`
|
||||
// against the client's before it registers anything.
|
||||
//
|
||||
// Duplicated rather than fetched, and that is deliberate. The value has to be on
|
||||
// `window.__rg` before the first module chunk evaluates, which is earlier than
|
||||
// any network round trip could answer — a fetched version would mean either an
|
||||
// await before render or a module reading `undefined`. The cost of the copy is
|
||||
// that the two files can drift, so a test asserts they agree
|
||||
// (client/test/moduleRegistry.test.js) rather than trusting a bump to remember
|
||||
// both.
|
||||
export const MODULE_API_VERSION = '1.0.0'
|
||||
@@ -1,8 +1,14 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'
|
||||
import MoonDot from '../../components/MoonDot.jsx'
|
||||
import BrandLogo from '../../components/BrandLogo.jsx'
|
||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||
import { applyNavOverrides } from '../../lib/navOverrides.js'
|
||||
import { useNavOverrides } from '../../lib/useNavOverrides.js'
|
||||
import { withModuleNav } from '../../modules/nav.js'
|
||||
import { useFeatureGate } from '../../modules/features.jsx'
|
||||
import { navItemVisibleTo, allowedPathsFor, isAllowedPath } from '../../lib/adminNav.js'
|
||||
|
||||
// Small inline stroke icons (16px, currentColor) — same style as ProviderIcon.
|
||||
// One shared frame keeps them terse; each item just supplies its path(s).
|
||||
@@ -38,13 +44,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 IconUser = () => <Icon><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></Icon>
|
||||
const IconShard = () => <Icon><path d="M12 2l7 6-7 14-7-14z" /><path d="M5 8h14" /></Icon>
|
||||
const IconNav = () => <Icon><path d="M4 6h16M4 12h16M4 18h10" /><circle cx="18" cy="18" r="2.5" /></Icon>
|
||||
const IconPalette = () => <Icon><path d="M12 3a9 9 0 1 0 0 18 2 2 0 0 0 1.6-3.2 2 2 0 0 1 1.6-3.2H18a3 3 0 0 0 3-3 9 9 0 0 0-9-8.6z" /><circle cx="7.5" cy="11.5" r="1" /><circle cx="10.5" cy="7.5" r="1" /><circle cx="15" cy="8.5" r="1" /></Icon>
|
||||
|
||||
// Nav is grouped into collapsible categories. A group with no `title` renders
|
||||
// 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
|
||||
// link that would 403; an item without `roles` is visible to everyone.
|
||||
// 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: [
|
||||
{ to: '/admin', label: 'Dashboard', end: true, icon: IconHome, roles: ['admin', 'editor', 'moderator'] },
|
||||
@@ -74,11 +86,14 @@ const NAV = [
|
||||
{ to: '/admin/users', label: 'Users', icon: IconUsers, roles: ['admin'] },
|
||||
{ to: '/admin/invites', label: 'Invites', icon: IconUsers, 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/auth-providers', label: 'Authentication', icon: IconKey, 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-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'] },
|
||||
],
|
||||
},
|
||||
@@ -92,6 +107,27 @@ const NAV = [
|
||||
|
||||
const COLLAPSE_KEY = 'admin.nav.collapsed'
|
||||
|
||||
// The one row an override may never hide: the nav editor itself, which is the
|
||||
// only screen that can un-hide anything. The write path already refuses it
|
||||
// (server/src/utils/navOverrides.js) and the editor's own toggle is disabled —
|
||||
// this is the third guard, and the one that also covers a row edited straight
|
||||
// in the database. Cheap, and it makes "cannot be hidden" true without
|
||||
// qualification.
|
||||
const UNHIDEABLE = '/admin/navigation'
|
||||
|
||||
function keepEditorReachable(overrides) {
|
||||
const entry = overrides?.[UNHIDEABLE]
|
||||
if (!entry || entry.hidden !== true) return overrides
|
||||
const { hidden, ...rest } = entry
|
||||
return { ...overrides, [UNHIDEABLE]: rest }
|
||||
}
|
||||
|
||||
// Who may see a sidebar row, and where that lets them go, both derived from the
|
||||
// row's own `roles` — lib/adminNav.js, which is where the two hardcoded path
|
||||
// lists this component used to carry went (MODULE_SYSTEM.md §1.4). Re-exported
|
||||
// because Admin -> Navigation has always imported it from here.
|
||||
export { navItemVisibleTo }
|
||||
|
||||
const TITLES = {
|
||||
'/admin': 'Dashboard',
|
||||
'/admin/posts': 'Posts',
|
||||
@@ -103,11 +139,14 @@ const TITLES = {
|
||||
'/admin/shard-ops': 'In-Game Ops',
|
||||
'/admin/houses': 'House Registry',
|
||||
'/admin/settings': 'Site Settings',
|
||||
'/admin/appearance': 'Appearance',
|
||||
'/admin/navigation': 'Navigation',
|
||||
'/admin/activity': 'Activity Log',
|
||||
'/admin/bot-activity': 'Web Bot Activity',
|
||||
'/admin/discord-bot': 'Discord Bot',
|
||||
'/admin/shard': 'Shard (uo-link)',
|
||||
'/admin/shard-visibility': 'Shard Visibility',
|
||||
'/admin/shard-atlas': 'Spawn Atlas',
|
||||
'/admin/characters': 'My Characters',
|
||||
'/admin/auth-providers': 'Authentication',
|
||||
'/admin/users': 'Users',
|
||||
@@ -115,6 +154,19 @@ const TITLES = {
|
||||
'/admin/account': 'Account Security',
|
||||
}
|
||||
|
||||
// An installed module's admin pages are not in TITLES and cannot be — core does
|
||||
// not know what they are called. Their nav row does, so the row is the title:
|
||||
// the longest matching module row wins, so a detail page under a section titles
|
||||
// as that section rather than falling through to a bare "Admin". Restricted to
|
||||
// rows a module registered, which is what keeps every core path resolving
|
||||
// through TITLES and sectionTitle exactly as it does today.
|
||||
function moduleTitle(baseNav, pathname) {
|
||||
return baseNav
|
||||
.flatMap((g) => g.items)
|
||||
.filter((i) => i.moduleId && (pathname === i.to || pathname.startsWith(`${i.to}/`)))
|
||||
.sort((a, b) => b.to.length - a.to.length)[0]?.label
|
||||
}
|
||||
|
||||
// Fallback page title for dynamic sub-routes not in the exact-match TITLES map.
|
||||
function sectionTitle(pathname) {
|
||||
if (pathname.startsWith('/admin/moderation')) return 'Moderation'
|
||||
@@ -139,27 +191,43 @@ const navBtnBase = {
|
||||
export default function AdminLayout() {
|
||||
const { user, logout } = useAuth()
|
||||
const { mode, siteTitle } = useSite()
|
||||
const navOverrides = useNavOverrides()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const title = TITLES[location.pathname] || sectionTitle(location.pathname)
|
||||
const isVisible = useFeatureGate()
|
||||
|
||||
// Core's rows plus every installed module's, before the override merge sees
|
||||
// them — so a module row is editable in Admin -> Navigation like any other
|
||||
// (modules/nav.js). Computed once: the registry is fixed before the first
|
||||
// render and nothing unregisters.
|
||||
const baseNav = useMemo(() => withModuleNav(NAV, 'admin'), [])
|
||||
const title =
|
||||
TITLES[location.pathname] || moduleTitle(baseNav, location.pathname) || sectionTitle(location.pathname)
|
||||
// The hero canvas editor needs room — let it use the full content width.
|
||||
const wide = location.pathname === '/admin/hero'
|
||||
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 MOD_PATHS = ['/admin/moderation', '/admin/moderation/appeals', '/admin/shard-ops', '/admin/houses', '/admin/account']
|
||||
const visible = (item) => {
|
||||
if (item.roles && !item.roles.includes(user?.role)) return false
|
||||
if (isModerator) return MOD_PATHS.includes(item.to)
|
||||
return true
|
||||
}
|
||||
// Drop items the current role can't see, then drop any now-empty group so an
|
||||
// empty category header never renders.
|
||||
const navGroups = NAV
|
||||
.map((g) => ({ ...g, items: g.items.filter(visible) }))
|
||||
.filter((g) => g.items.length > 0)
|
||||
|
||||
// An admin may relabel, reorder, hide and regroup these rows from Admin →
|
||||
// Navigation. The merge runs FIRST and the role filter after it, so the filter
|
||||
// stays the boundary: an override cannot show a moderator a row their role
|
||||
// gate hides, whatever it says. With no stored row applyNavOverrides returns
|
||||
// NAV itself and this is exactly the code that ran before the feature.
|
||||
const navGroups = useMemo(
|
||||
() =>
|
||||
applyNavOverrides(baseNav, keepEditorReachable(navOverrides.nav_admin))
|
||||
.map((g) => ({
|
||||
...g,
|
||||
// `isVisible` is a no-op for every core row — none carries a `feature`
|
||||
// — and is applied here so that a module row which does carry one is
|
||||
// gated on the sidebar rather than silently advertised.
|
||||
items: g.items.filter((item) => navItemVisibleTo(item, user?.role) && isVisible(item)),
|
||||
}))
|
||||
// Drop any now-empty group so an empty category header never renders.
|
||||
.filter((g) => g.items.length > 0),
|
||||
[baseNav, navOverrides.nav_admin, user?.role, isVisible],
|
||||
)
|
||||
|
||||
// Accordion: track which titled categories are collapsed. Persist across
|
||||
// reloads; default all-open. The group holding the active route auto-opens.
|
||||
@@ -185,17 +253,22 @@ export default function AdminLayout() {
|
||||
g.title && g.items.some((i) => (i.end ? location.pathname === i.to : location.pathname.startsWith(i.to)))
|
||||
)?.title
|
||||
|
||||
// Where a moderator may go, from the same `roles` that decide what they see.
|
||||
// It used to be a third hardcoded list — a prefix check over three paths —
|
||||
// which disagreed with the sidebar's own five-path allowlist: `/admin/houses`
|
||||
// was on the sidebar and not in the redirect, so a moderator who clicked
|
||||
// Houses in their own nav was bounced straight back to Moderation. One
|
||||
// derivation cannot disagree with itself, which is the point of deriving it.
|
||||
const allowed = useMemo(() => allowedPathsFor(baseNav, user?.role), [baseNav, user?.role])
|
||||
|
||||
// Confine a moderator who deep-links (or is redirected to the index) to a page
|
||||
// outside their remit — the API would 403 anyway, so send them to their home.
|
||||
useEffect(() => {
|
||||
if (!isModerator) return
|
||||
const p = location.pathname
|
||||
const allowed =
|
||||
p.startsWith('/admin/moderation') || p.startsWith('/admin/shard-ops') || p === '/admin/account'
|
||||
if (!allowed) {
|
||||
if (!isAllowedPath(location.pathname, allowed)) {
|
||||
navigate('/admin/moderation', { replace: true })
|
||||
}
|
||||
}, [isModerator, location.pathname, navigate])
|
||||
}, [isModerator, location.pathname, navigate, allowed])
|
||||
|
||||
// Keep the admin out of search indexes (belt-and-suspenders with robots.txt).
|
||||
useEffect(() => {
|
||||
@@ -226,6 +299,7 @@ export default function AdminLayout() {
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: '22px 22px 18px', borderBottom: '1px solid var(--line-soft)', display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<BrandLogo height={24} />
|
||||
<MoonDot />
|
||||
<div>
|
||||
<div className="display" style={{ fontSize: '1.02rem', color: 'var(--head)', letterSpacing: '0.03em' }}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom'
|
||||
import MoonDot from '../../components/MoonDot.jsx'
|
||||
import BrandLogo from '../../components/BrandLogo.jsx'
|
||||
import ProviderIcon from '../../components/ProviderIcon.jsx'
|
||||
import TrustLimitModal from '../../components/security/TrustLimitModal.jsx'
|
||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
@@ -181,6 +182,10 @@ export default function AdminLogin() {
|
||||
<div style={{ width: '100%', maxWidth: 400 }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 26 }}>
|
||||
<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} />
|
||||
</div>
|
||||
<h1 className="display" style={{ margin: 0, fontSize: '1.7rem', letterSpacing: '0.04em', color: 'var(--head)' }}>
|
||||
|
||||
358
client/src/routes/admin/views/AppearanceAdmin.jsx
Normal file
358
client/src/routes/admin/views/AppearanceAdmin.jsx
Normal 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 & 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
|
||||
site’s 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,
|
||||
}
|
||||
213
client/src/routes/admin/views/BrandAssetsPanel.jsx
Normal file
213
client/src/routes/admin/views/BrandAssetsPanel.jsx
Normal 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 footer’s “powered by
|
||||
Runic Gateway” mark is the project’s badge, not this instance’s, and never changes.
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const linkBtn = {
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: 'var(--accent)',
|
||||
fontSize: '0.72rem',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
}
|
||||
560
client/src/routes/admin/views/NavEditor.jsx
Normal file
560
client/src/routes/admin/views/NavEditor.jsx
Normal file
@@ -0,0 +1,560 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
import { useAuth } from '../../../contexts/AuthContext.jsx'
|
||||
import { useSite } from '../../../contexts/SiteContext.jsx'
|
||||
import { withModuleNav } from '../../../modules/nav.js'
|
||||
import { useFeatureGate } from '../../../modules/features.jsx'
|
||||
import { buildNavRows, buildNavOverrides, buildPublicNav, buildPublicNavOverrides } from '../../../lib/navOverrides.js'
|
||||
import PublicNavTree from './PublicNavTree.jsx'
|
||||
import { parseJsonSetting } from '../../../lib/settingsJson.js'
|
||||
import { refreshNavOverrides } from '../../../lib/useNavOverrides.js'
|
||||
import { NAV as PUBLIC_NAV } from '../../../components/SiteHeader.jsx'
|
||||
import { NAV as ADMIN_NAV, navItemVisibleTo } from '../AdminLayout.jsx'
|
||||
import { NAV as PLAYER_NAV } from '../../player/PlayerPortalLayout.jsx'
|
||||
|
||||
// Admin · Navigation — phases 6-8 of docs/website/THEMING_AND_NAV.md.
|
||||
//
|
||||
// The three navs stay declared in code, each in the component that renders it;
|
||||
// this screen writes an override *layer* over them (§7). It can relabel,
|
||||
// reorder, hide and — on the admin sidebar — move a row into another existing
|
||||
// section, and nothing else. It cannot introduce a route and it cannot touch a
|
||||
// `roles` or `feature` gate, so the filters in the layouts still decide who sees
|
||||
// what, and they run after the merge.
|
||||
//
|
||||
// Three things shape the screen:
|
||||
//
|
||||
// • The palette is filtered to the editing admin's OWN visible rows (§8.1) —
|
||||
// the base array run through their role and 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 isVisible = useFeatureGate()
|
||||
const [tab, setTab] = useState('nav_public')
|
||||
// Per nav: the editable groups, the overrides as loaded (so a row this admin
|
||||
// cannot see survives their save), and whether a settings row exists at all.
|
||||
const [state, setState] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [saved, setSaved] = useState('')
|
||||
const [dirty, setDirty] = useState({})
|
||||
|
||||
// The nav as coded, unfiltered. The palette below is what this admin may EDIT;
|
||||
// this is what still EXISTS, and the two are different questions. Saving needs
|
||||
// both: an entry for a row their palette filtered out must be carried through
|
||||
// rather than reset, and only an entry for a route the code no longer declares
|
||||
// at all should be dropped.
|
||||
//
|
||||
// Each nav is the coded array with every installed module's rows already
|
||||
// interleaved (modules/nav.js) — the same array the layout renders, which is
|
||||
// what makes a module row editable here at all: the override merge is keyed by
|
||||
// `to` and drops a key the base it is handed does not declare, so a nav built
|
||||
// from core alone would silently discard every stored override on a module row
|
||||
// the moment it was saved.
|
||||
const fullNavs = useMemo(
|
||||
() => ({
|
||||
nav_public: withModuleNav(PUBLIC_NAV, 'public'),
|
||||
nav_admin: withModuleNav(ADMIN_NAV, 'admin'),
|
||||
nav_player: withModuleNav(PLAYER_NAV, 'player'),
|
||||
}),
|
||||
[],
|
||||
)
|
||||
|
||||
// The palette: each base nav, filtered to what THIS admin can see (§8.1). Two
|
||||
// gates, and neither is core's own opinion any more — `roles` on a row, and
|
||||
// the owning module's answer for a row that names a `feature`.
|
||||
const palettes = useMemo(
|
||||
() => ({
|
||||
nav_public: fullNavs.nav_public.filter(isVisible),
|
||||
nav_admin: fullNavs.nav_admin
|
||||
.map((g) => ({ ...g, items: g.items.filter((i) => navItemVisibleTo(i, user?.role) && isVisible(i)) }))
|
||||
.filter((g) => g.items.length > 0),
|
||||
nav_player: fullNavs.nav_player.filter(isVisible),
|
||||
}),
|
||||
[fullNavs, isVisible, user?.role],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
api.admin
|
||||
.getSettings()
|
||||
.then((all) => {
|
||||
if (!active) return
|
||||
const next = {}
|
||||
for (const { key } of TABS) {
|
||||
const stored = parseJsonSetting(all[key])
|
||||
// The public header is a tree (sections are entries in the top-level
|
||||
// order); the other two are the fixed-frame grouped/flat shape.
|
||||
next[key] =
|
||||
key === 'nav_public'
|
||||
? { stored, hasRow: Boolean(all[key]), tree: buildPublicNav(palettes[key], stored, { keepHidden: true }) }
|
||||
: { stored, hasRow: Boolean(all[key]), groups: buildNavRows(palettes[key], stored) }
|
||||
}
|
||||
setState(next)
|
||||
})
|
||||
.catch(() => active && setError('Could not load the navigation settings.'))
|
||||
.finally(() => active && setLoading(false))
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
// Loaded once; the palettes settle before the fetch resolves in practice, and
|
||||
// re-running on a feature flip would discard the admin's unsaved edits.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
)
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error && !state) return <ErrorState message={error} />
|
||||
|
||||
const current = state[tab]
|
||||
const isPublic = tab === 'nav_public'
|
||||
const groupTitles = isPublic ? [] : current.groups.map((g) => g.title).filter(Boolean)
|
||||
// Where each row is declared in code, so the section dropdown can offer only
|
||||
// the destinations an override is able to express.
|
||||
const baseGroups = new Map(
|
||||
(!isPublic && Array.isArray(palettes[tab]) && palettes[tab][0]?.items
|
||||
? palettes[tab].flatMap((g) => g.items.map((i) => [i.to, g.title ?? null]))
|
||||
: []),
|
||||
)
|
||||
// The admin sidebar can only move a row between the four coded sections, and
|
||||
// "(no section)" only for a row coded into an untitled one — for anything else
|
||||
// it is a move an override cannot express (§6.4), so offering it would
|
||||
// silently do nothing.
|
||||
const groupDestinations = (baseGroup) => [
|
||||
...(baseGroup === null ? [{ value: '', label: '(no section)' }] : []),
|
||||
...groupTitles.map((t) => ({ value: t, label: t })),
|
||||
]
|
||||
|
||||
function mutate(updater) {
|
||||
setState((s) => ({ ...s, [tab]: { ...s[tab], groups: updater(s[tab].groups) } }))
|
||||
setDirty((d) => ({ ...d, [tab]: true }))
|
||||
setSaved('')
|
||||
}
|
||||
|
||||
function setTree(tree) {
|
||||
setState((s) => ({ ...s, [tab]: { ...s[tab], tree } }))
|
||||
setDirty((d) => ({ ...d, [tab]: true }))
|
||||
setSaved('')
|
||||
}
|
||||
|
||||
const onRowChange = (next) =>
|
||||
mutate((groups) => groups.map((g) => ({ ...g, items: g.items.map((i) => (i.to === next.to ? next : i)) })))
|
||||
|
||||
// Sections change by dropdown, not by dragging: a drag that could land in
|
||||
// another list is a lot of interaction surface for something an admin does
|
||||
// once, and this keeps every drag a simple reorder. The row goes to the end of
|
||||
// its new section, where it is visible and can then be dragged into place.
|
||||
const onMoveGroup = (to, title) =>
|
||||
mutate((groups) => {
|
||||
const moving = groups.flatMap((g) => g.items).find((i) => i.to === to)
|
||||
if (!moving) return groups
|
||||
return groups.map((g) => {
|
||||
if ((g.title ?? null) === title) return { ...g, items: [...g.items.filter((i) => i.to !== to), moving] }
|
||||
return { ...g, items: g.items.filter((i) => i.to !== to) }
|
||||
})
|
||||
})
|
||||
|
||||
const onDragEnd = (groupIndex) => (event) => {
|
||||
const { active, over } = event
|
||||
if (!over || active.id === over.id) return
|
||||
mutate((groups) =>
|
||||
groups.map((g, i) => {
|
||||
if (i !== groupIndex) return g
|
||||
const from = g.items.findIndex((it) => it.to === active.id)
|
||||
const to = g.items.findIndex((it) => it.to === over.id)
|
||||
if (from < 0 || to < 0) return g
|
||||
return { ...g, items: arrayMove(g.items, from, to) }
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// Push a save into whatever is rendering that nav right now, so the admin sees
|
||||
// what they just did: the header re-reads the public settings, the two
|
||||
// authenticated sidebars re-read /settings/nav.
|
||||
async function propagate(key) {
|
||||
if (key === 'nav_public') await refreshSite()
|
||||
else await refreshNavOverrides()
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
const overrides = isPublic
|
||||
? buildPublicNavOverrides(current.tree, fullNavs[tab], current.stored)
|
||||
: buildNavOverrides(current.groups, fullNavs[tab], current.stored)
|
||||
// A wrapper with an empty `items` and no sections/links says nothing
|
||||
// either, so "empty" is about the whole value, not just its key count.
|
||||
const empty =
|
||||
Object.keys(overrides).length === 0 ||
|
||||
(overrides.items !== undefined &&
|
||||
Object.keys(overrides.items).length === 0 &&
|
||||
!overrides.sections?.length &&
|
||||
!overrides.links?.length)
|
||||
// Nothing differs from the code default, so there is nothing to store —
|
||||
// and a row that says nothing would still read as "this nav was
|
||||
// customised". Delete it instead (§2, §4.1).
|
||||
if (empty) await api.admin.resetSetting(tab)
|
||||
else await api.admin.updateSettings({ [tab]: overrides })
|
||||
setState((s) => ({
|
||||
...s,
|
||||
[tab]: { ...s[tab], stored: empty ? null : overrides, hasRow: !empty },
|
||||
}))
|
||||
setDirty((d) => ({ ...d, [tab]: false }))
|
||||
setSaved(tab)
|
||||
await propagate(tab)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not save this navigation.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function resetNav() {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.admin.resetSetting(tab)
|
||||
setState((s) => ({
|
||||
...s,
|
||||
[tab]: isPublic
|
||||
? { stored: null, hasRow: false, tree: buildPublicNav(palettes[tab], null, { keepHidden: true }) }
|
||||
: { stored: null, hasRow: false, groups: buildNavRows(palettes[tab], null) },
|
||||
}))
|
||||
setDirty((d) => ({ ...d, [tab]: false }))
|
||||
setSaved('')
|
||||
await propagate(tab)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not reset this navigation.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const activeTab = TABS.find((t) => t.key === tab)
|
||||
|
||||
return (
|
||||
<section style={{ maxWidth: 860, display: 'flex', flexDirection: 'column', gap: 22 }}>
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.82rem', lineHeight: 1.7 }}>
|
||||
Rename, reorder and hide the entries in each navigation. The pages themselves are unchanged — this
|
||||
only decides what is advertised, and it can never show anyone a link their role or this shard’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>
|
||||
)
|
||||
}
|
||||
@@ -177,14 +177,15 @@ const delStyle = {
|
||||
}
|
||||
|
||||
// ── Announcement status panel ────────────────────────────────────────────────
|
||||
// Shows the town-crier + Discord delivery state for a published news post and
|
||||
// offers a per-leg retry (useful after fixing the sidecar / news channel without
|
||||
// re-publishing). Only rendered for news posts in edit mode; renders nothing
|
||||
// until the post has actually been announced (no job row yet → nothing to show).
|
||||
const LEG_META = {
|
||||
towncrier: { label: 'In-game town crier' },
|
||||
discord: { label: 'Discord #news' },
|
||||
}
|
||||
// Shows each delivery leg's state for a published news post and offers a per-leg
|
||||
// retry (useful after fixing the sidecar / news channel without re-publishing).
|
||||
// Only rendered for news posts in edit mode; renders nothing until the post has
|
||||
// actually been announced (no job row yet → nothing to show).
|
||||
//
|
||||
// The legs and their labels come from the JOB, not from a constant here: which
|
||||
// legs exist is decided by what the server has registered, so an installed module
|
||||
// brings its own leg and this panel renders it with no client change
|
||||
// (docs/website/MODULE_SYSTEM.md §1.8).
|
||||
const STATUS_STYLE = {
|
||||
done: { color: '#7bbf8f', label: 'delivered' },
|
||||
pending: { color: '#d9b84a', label: 'pending' },
|
||||
@@ -227,14 +228,12 @@ function AnnouncePanel({ postId }) {
|
||||
return (
|
||||
<div style={panelStyle}>
|
||||
<span className="field-label" style={{ marginBottom: 2 }}>Announcement</span>
|
||||
{['towncrier', 'discord'].map((leg) => {
|
||||
const status = job[`${leg}_status`]
|
||||
const err = job[`${leg}_last_error`]
|
||||
{(job.legs || []).map(({ leg, label, status, last_error: err }) => {
|
||||
const s = STATUS_STYLE[status] || STATUS_STYLE.pending
|
||||
return (
|
||||
<div key={leg} style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span className="sans" style={{ fontSize: '0.85rem', minWidth: 140 }}>{LEG_META[leg].label}</span>
|
||||
<span className="sans" style={{ fontSize: '0.85rem', minWidth: 140 }}>{label}</span>
|
||||
<span className="sans" style={{ fontSize: '0.8rem', color: s.color, fontWeight: 600 }}>● {s.label}</span>
|
||||
{status !== 'done' && (
|
||||
<button
|
||||
|
||||
310
client/src/routes/admin/views/PublicNavTree.jsx
Normal file
310
client/src/routes/admin/views/PublicNavTree.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -155,7 +155,7 @@ export default function ShardAdmin() {
|
||||
const [baseUrl, setBaseUrl] = useState('')
|
||||
const [wsUrl, setWsUrl] = useState('')
|
||||
const [token, setToken] = useState('')
|
||||
const [protocol, setProtocol] = useState(1)
|
||||
const [protocol, setProtocol] = useState(3)
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
@@ -172,7 +172,7 @@ export default function ShardAdmin() {
|
||||
if (!initializedRef.current) {
|
||||
setBaseUrl(c.baseUrl || '')
|
||||
setWsUrl(c.wsUrl || '')
|
||||
setProtocol(c.protocol || 1)
|
||||
setProtocol(c.protocol || 3)
|
||||
setEnabled(c.enabled)
|
||||
initializedRef.current = true
|
||||
}
|
||||
|
||||
@@ -65,8 +65,15 @@ const FIELD_LABEL = {
|
||||
price: 'House price',
|
||||
location: 'In-game location (map + coordinates)',
|
||||
connect: 'Server connect address',
|
||||
characterName: 'Character names',
|
||||
// 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 }) {
|
||||
|
||||
285
client/src/routes/admin/views/SpawnAtlas.jsx
Normal file
285
client/src/routes/admin/views/SpawnAtlas.jsx
Normal 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 shard’s own ServUO files.
|
||||
It refreshes itself on every server start; everything here is for the times you don’t 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 shard’s 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>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
import { useMemo } from 'react'
|
||||
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'
|
||||
import MoonDot from '../../components/MoonDot.jsx'
|
||||
import BrandLogo from '../../components/BrandLogo.jsx'
|
||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||
import { applyNavOverrides } from '../../lib/navOverrides.js'
|
||||
import { useNavOverrides } from '../../lib/useNavOverrides.js'
|
||||
import { withModuleNav } from '../../modules/nav.js'
|
||||
import { useFeatureGate } from '../../modules/features.jsx'
|
||||
|
||||
// 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
|
||||
@@ -30,7 +36,12 @@ 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 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 CORE row
|
||||
// carries a gate — every player sees all three — but an installed module's rows
|
||||
// join this list before the merge and may carry a `feature`, so the filter after
|
||||
// it is not dead code.
|
||||
export const NAV = [
|
||||
{ to: '/player', label: 'Characters', end: true, icon: IconUser },
|
||||
{ to: '/account/appeals', label: 'Appeals', icon: IconShield },
|
||||
{ to: '/account', label: 'Account', end: true, icon: IconGear },
|
||||
@@ -60,6 +71,13 @@ const navBtnBase = {
|
||||
export default function PlayerPortalLayout() {
|
||||
const { user, logout } = useAuth()
|
||||
const { siteTitle } = useSite()
|
||||
const navOverrides = useNavOverrides()
|
||||
const isVisible = useFeatureGate()
|
||||
const baseNav = useMemo(() => withModuleNav(NAV, 'player'), [])
|
||||
const nav = useMemo(
|
||||
() => applyNavOverrides(baseNav, navOverrides.nav_player).filter(isVisible),
|
||||
[baseNav, navOverrides.nav_player, isVisible],
|
||||
)
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const title =
|
||||
@@ -86,6 +104,7 @@ export default function PlayerPortalLayout() {
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: '22px 22px 18px', borderBottom: '1px solid var(--line-soft)', display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<BrandLogo height={24} />
|
||||
<MoonDot />
|
||||
<div>
|
||||
<div className="display" style={{ fontSize: '1.02rem', color: 'var(--head)', letterSpacing: '0.03em' }}>
|
||||
@@ -98,7 +117,7 @@ export default function PlayerPortalLayout() {
|
||||
</div>
|
||||
|
||||
<nav style={{ flex: 1, padding: '14px 12px', display: 'flex', flexDirection: 'column', gap: 4, overflowY: 'auto' }}>
|
||||
{NAV.map((n) => (
|
||||
{nav.map((n) => (
|
||||
<NavLink
|
||||
key={n.to}
|
||||
to={n.to}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import MoonDot from '../../components/MoonDot.jsx'
|
||||
import BrandLogo from '../../components/BrandLogo.jsx'
|
||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||
|
||||
// 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={{ textAlign: 'center', marginBottom: 26 }}>
|
||||
<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} />
|
||||
</div>
|
||||
<h1 className="display" style={{ margin: 0, fontSize: '1.7rem', letterSpacing: '0.04em', color: 'var(--head)' }}>
|
||||
|
||||
310
client/src/routes/public/Atlas.jsx
Normal file
310
client/src/routes/public/Atlas.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
201
client/src/routes/public/AtlasCreature.jsx
Normal file
201
client/src/routes/public/AtlasCreature.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
240
client/src/routes/public/Leaderboards.jsx
Normal file
240
client/src/routes/public/Leaderboards.jsx
Normal 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' }}>—</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>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import MoonDot from '../../components/MoonDot.jsx'
|
||||
import BrandLogo from '../../components/BrandLogo.jsx'
|
||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||
|
||||
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={{ marginBottom: 26 }}>
|
||||
<BrandLogo height={40} style={{ margin: '0 auto 16px' }} />
|
||||
<MoonDot size={18} glow={0.6} />
|
||||
</div>
|
||||
<p className="eyebrow" style={{ color: '#c2d2e6', letterSpacing: '0.24em' }}>
|
||||
|
||||
325
client/src/routes/public/Market.jsx
Normal file
325
client/src/routes/public/Market.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
102
client/src/routes/public/MarketVendor.jsx
Normal file
102
client/src/routes/public/MarketVendor.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -24,6 +24,22 @@
|
||||
|
||||
--shadow-card: 0 14px 34px rgba(0, 0, 0, 0.3);
|
||||
--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;
|
||||
padding: 24px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius-card);
|
||||
text-decoration: none;
|
||||
color: var(--ink);
|
||||
background: var(--panel-grad);
|
||||
@@ -123,19 +139,19 @@ a.card:focus-visible {
|
||||
}
|
||||
.panel {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--panel-grad);
|
||||
}
|
||||
.panel-flat {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius-panel);
|
||||
overflow: hidden;
|
||||
background: var(--panel-flat);
|
||||
}
|
||||
.note {
|
||||
border: 1px solid var(--line);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-input);
|
||||
background: rgba(19, 36, 60, 0.4);
|
||||
padding: 18px 22px;
|
||||
color: var(--muted);
|
||||
@@ -168,12 +184,20 @@ a.card:focus-visible {
|
||||
/* ===== Pills / buttons ===== */
|
||||
.pill {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
border-radius: var(--radius-pill);
|
||||
padding: 7px 14px;
|
||||
color: var(--muted);
|
||||
background: rgba(11, 22, 48, 0.5);
|
||||
font-family: var(--sans);
|
||||
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;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||
@@ -186,7 +210,7 @@ a.card:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
.btn {
|
||||
border-radius: 999px;
|
||||
border-radius: var(--radius-pill);
|
||||
padding: 12px 26px;
|
||||
font-family: var(--sans);
|
||||
font-size: 0.92rem;
|
||||
@@ -214,7 +238,7 @@ a.card:focus-visible {
|
||||
background: var(--blue);
|
||||
}
|
||||
.btn-sq {
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-input);
|
||||
padding: 10px 18px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
@@ -230,7 +254,7 @@ button[disabled] {
|
||||
.select {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-input);
|
||||
padding: 11px 14px;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
@@ -312,7 +336,7 @@ button[disabled] {
|
||||
}
|
||||
.prose img {
|
||||
max-width: 100%;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-input);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
@@ -320,7 +344,7 @@ button[disabled] {
|
||||
.rte {
|
||||
position: relative;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-input);
|
||||
background: var(--bg);
|
||||
}
|
||||
.rte:focus-within {
|
||||
@@ -407,7 +431,7 @@ button[disabled] {
|
||||
width: min(360px, calc(100% - 20px));
|
||||
padding: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-input);
|
||||
background: var(--panel-a);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
@@ -449,7 +473,7 @@ button[disabled] {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: rgba(127, 153, 189, 0.1);
|
||||
color: var(--accent);
|
||||
font-family: var(--sans);
|
||||
@@ -494,7 +518,7 @@ button[disabled] {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-input);
|
||||
background: var(--panel-flat);
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
@@ -532,7 +556,7 @@ button[disabled] {
|
||||
overflow-y: auto;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-input);
|
||||
background: var(--bg);
|
||||
}
|
||||
.diff-add {
|
||||
@@ -603,7 +627,7 @@ button[disabled] {
|
||||
vertical-align: middle;
|
||||
}
|
||||
.badge {
|
||||
border-radius: 999px;
|
||||
border-radius: var(--radius-pill);
|
||||
padding: 3px 11px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
@@ -780,7 +804,7 @@ button[disabled] {
|
||||
}
|
||||
.page-image img {
|
||||
max-width: 100%;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-input);
|
||||
border: 1px solid var(--line);
|
||||
display: block;
|
||||
}
|
||||
@@ -863,7 +887,7 @@ button[disabled] {
|
||||
}
|
||||
.pb-column-editor {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-input);
|
||||
padding: 12px;
|
||||
background: var(--panel-flat, transparent);
|
||||
}
|
||||
@@ -881,7 +905,7 @@ button[disabled] {
|
||||
}
|
||||
.pb-subblock {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-input);
|
||||
padding: 10px;
|
||||
margin-top: 10px;
|
||||
background: var(--bg);
|
||||
@@ -919,7 +943,7 @@ button[disabled] {
|
||||
border: 1px solid #6e3b38;
|
||||
background: rgba(110, 59, 56, 0.16);
|
||||
color: #e6a9a3;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-input);
|
||||
padding: 10px 14px;
|
||||
margin-top: 14px;
|
||||
font-size: 0.86rem;
|
||||
@@ -928,7 +952,7 @@ button[disabled] {
|
||||
border: 1px solid var(--accent);
|
||||
background: var(--blue);
|
||||
color: var(--accent-bright);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-input);
|
||||
padding: 8px 14px;
|
||||
margin-top: 14px;
|
||||
font-size: 0.86rem;
|
||||
@@ -960,7 +984,7 @@ button[disabled] {
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius-card);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.pb-canvas {
|
||||
@@ -970,7 +994,7 @@ button[disabled] {
|
||||
}
|
||||
.pb-block-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--panel-flat, transparent);
|
||||
}
|
||||
.pb-block-card.is-dragging {
|
||||
@@ -1082,7 +1106,7 @@ button[disabled] {
|
||||
border: 1px solid var(--accent);
|
||||
background: var(--blue);
|
||||
color: var(--accent-bright);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-input);
|
||||
padding: 8px 14px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 0.85rem;
|
||||
|
||||
125
client/test/adminNav.test.js
Normal file
125
client/test/adminNav.test.js
Normal file
@@ -0,0 +1,125 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { navItemVisibleTo, allowedPathsFor, isAllowedPath } from '../src/lib/adminNav.js'
|
||||
|
||||
// Moderator confinement, derived from each row's `roles` (Phase 2 PR 8 —
|
||||
// MODULE_SYSTEM.md §1.4). This replaced two hardcoded path lists that had
|
||||
// drifted apart from each other, so the tests worth having are the ones that
|
||||
// pin what a moderator may now see and reach, and the shape of the match.
|
||||
|
||||
// The real sidebar, trimmed to the rows that decide something here.
|
||||
const NAV = [
|
||||
{ items: [{ to: '/admin', label: 'Dashboard', end: true, roles: ['admin', 'editor', 'moderator'] }] },
|
||||
{
|
||||
title: 'Moderation',
|
||||
items: [
|
||||
{ to: '/admin/moderation', label: 'Moderation', roles: ['admin', 'moderator'] },
|
||||
{ to: '/admin/moderation/appeals', label: 'Appeals', roles: ['admin', 'moderator'] },
|
||||
{ to: '/admin/shard-ops', label: 'In-Game Ops', roles: ['admin', 'moderator'] },
|
||||
{ to: '/admin/houses', label: 'Houses', roles: ['admin', 'moderator'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'System',
|
||||
items: [
|
||||
{ to: '/admin/users', label: 'Users', roles: ['admin'] },
|
||||
{ to: '/admin/settings', label: 'Settings', roles: ['admin'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
items: [
|
||||
{ to: '/admin/characters', label: 'My Characters' },
|
||||
{ to: '/admin/account', label: 'Account' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const visibleTo = (role) =>
|
||||
NAV.flatMap((g) => g.items)
|
||||
.filter((i) => navItemVisibleTo(i, role))
|
||||
.map((i) => i.to)
|
||||
|
||||
test('a row with no roles is visible to every staff role', () => {
|
||||
// Self-service: staff are a superset of players, so a moderator reaching their
|
||||
// own characters is not a privilege, it is the thing every account has.
|
||||
for (const role of ['admin', 'editor', 'moderator']) {
|
||||
assert.equal(navItemVisibleTo({ to: '/admin/account' }, role), true)
|
||||
}
|
||||
})
|
||||
|
||||
test('a role not named on the row cannot see it', () => {
|
||||
assert.equal(navItemVisibleTo({ to: '/admin/users', roles: ['admin'] }, 'moderator'), false)
|
||||
assert.equal(navItemVisibleTo({ to: '/admin/users', roles: ['admin'] }, 'admin'), true)
|
||||
// An unknown or absent role sees only the ungated rows.
|
||||
assert.equal(navItemVisibleTo({ to: '/admin/users', roles: ['admin'] }, undefined), false)
|
||||
assert.equal(navItemVisibleTo({ to: '/admin/account' }, undefined), true)
|
||||
})
|
||||
|
||||
test('what a moderator sees is exactly the moderation section, plus self-service', () => {
|
||||
// The two additions the derivation makes over the old MOD_PATHS list are
|
||||
// Dashboard — whose roles have always named moderator, so the two lists
|
||||
// disagreed — and My Characters. Both are already permitted server-side.
|
||||
assert.deepEqual(visibleTo('moderator'), [
|
||||
'/admin',
|
||||
'/admin/moderation',
|
||||
'/admin/moderation/appeals',
|
||||
'/admin/shard-ops',
|
||||
'/admin/houses',
|
||||
'/admin/characters',
|
||||
'/admin/account',
|
||||
])
|
||||
})
|
||||
|
||||
test('an admin still sees everything and an editor still sees nothing extra', () => {
|
||||
assert.equal(visibleTo('admin').length, NAV.flatMap((g) => g.items).length)
|
||||
assert.deepEqual(visibleTo('editor'), ['/admin', '/admin/characters', '/admin/account'])
|
||||
})
|
||||
|
||||
test('a row with `end` matches exactly — the dashboard is not a prefix', () => {
|
||||
// The bug this shape exists to prevent: treating `/admin` as a prefix would
|
||||
// make every path in the admin area allowed for anyone who can see Dashboard.
|
||||
const allowed = allowedPathsFor(NAV, 'moderator')
|
||||
assert.equal(isAllowedPath('/admin', allowed), true)
|
||||
assert.equal(isAllowedPath('/admin/users', allowed), false)
|
||||
assert.equal(isAllowedPath('/admin/users/12', allowed), false)
|
||||
})
|
||||
|
||||
test('every other row covers its own sub-routes', () => {
|
||||
const allowed = allowedPathsFor(NAV, 'moderator')
|
||||
assert.equal(isAllowedPath('/admin/moderation/appeals/12', allowed), true)
|
||||
assert.equal(isAllowedPath('/admin/characters/0x4001', allowed), true)
|
||||
})
|
||||
|
||||
test('a sibling path that merely shares a prefix is NOT covered', () => {
|
||||
const allowed = allowedPathsFor(NAV, 'moderator')
|
||||
// `/admin/houses-secret` starts with `/admin/houses` as a string; the match is
|
||||
// on path segments, so it does not start with `/admin/houses/`.
|
||||
assert.equal(isAllowedPath('/admin/houses-secret', allowed), false)
|
||||
assert.equal(isAllowedPath('/admin/houses/42', allowed), true)
|
||||
})
|
||||
|
||||
test('Houses is reachable, which is the defect the derivation fixed', () => {
|
||||
// The redirect used to allow only /admin/moderation*, /admin/shard-ops* and
|
||||
// /admin/account, while the sidebar showed Houses — so a moderator clicking a
|
||||
// row in their own nav was bounced back to Moderation.
|
||||
const allowed = allowedPathsFor(NAV, 'moderator')
|
||||
assert.equal(isAllowedPath('/admin/houses', allowed), true)
|
||||
})
|
||||
|
||||
test('a module row a moderator may see is reachable without core listing it', () => {
|
||||
// The reason this is derived at all: core cannot hardcode a path it has never
|
||||
// heard of, and a module row arrives with `roles` like any other.
|
||||
const withModule = [
|
||||
...NAV,
|
||||
{ title: 'Shard', items: [{ to: '/admin/uo/shard-ops', label: 'Ops', roles: ['admin', 'moderator'], moduleId: 'uo' }] },
|
||||
]
|
||||
const allowed = allowedPathsFor(withModule, 'moderator')
|
||||
assert.equal(isAllowedPath('/admin/uo/shard-ops', allowed), true)
|
||||
assert.equal(isAllowedPath('/admin/uo/shard-ops/queue', allowed), true)
|
||||
})
|
||||
|
||||
test('a nav that is not there does not throw', () => {
|
||||
assert.deepEqual(allowedPathsFor(null, 'moderator'), [])
|
||||
assert.equal(isAllowedPath('/admin', undefined), false)
|
||||
})
|
||||
@@ -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.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' }))
|
||||
})
|
||||
|
||||
85
client/test/featureGate.test.js
Normal file
85
client/test/featureGate.test.js
Normal file
@@ -0,0 +1,85 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { buildFeatureGate, OPEN_GATE } from '../src/modules/featureGate.js'
|
||||
|
||||
// The feature seam's decision logic (MODULE_SYSTEM.md §1.5, MODULE_API.md §3.3).
|
||||
// Every branch here fails OPEN, and that is the property under test as much as
|
||||
// the happy path: this is presentation, the server is the gate, and a UI mistake
|
||||
// that hides a page from someone entitled to it is worse in every case than one
|
||||
// that shows a link which then 403s.
|
||||
|
||||
const flags = (...names) => new Set(names)
|
||||
|
||||
test('a row with no feature is always visible', () => {
|
||||
const gate = buildFeatureGate(new Map([['uo', flags()]]))
|
||||
assert.equal(gate({ to: '/site/news' }), true)
|
||||
})
|
||||
|
||||
test('a core row resolves against the owner id `core`', () => {
|
||||
// Core's ten shard-gated rows carry no moduleId, and core registers its
|
||||
// provider under `core` (main.jsx) precisely so they resolve without one.
|
||||
const gate = buildFeatureGate(new Map([['core', flags('atlas')]]))
|
||||
assert.equal(gate({ to: '/site/atlas', feature: 'atlas' }), true)
|
||||
assert.equal(gate({ to: '/site/market', feature: 'market' }), false)
|
||||
})
|
||||
|
||||
test('a module row resolves against ITS module, not another one', () => {
|
||||
const gate = buildFeatureGate(
|
||||
new Map([
|
||||
['uo', flags('atlas')],
|
||||
['rust', flags('market')],
|
||||
]),
|
||||
)
|
||||
assert.equal(gate({ to: '/uo/atlas', feature: 'atlas', moduleId: 'uo' }), true)
|
||||
// `market` is a flag the OTHER module grants. Resolution is by registration,
|
||||
// so there is no string a module can write to borrow it.
|
||||
assert.equal(gate({ to: '/uo/market', feature: 'market', moduleId: 'uo' }), false)
|
||||
assert.equal(gate({ to: '/rust/market', feature: 'market', moduleId: 'rust' }), true)
|
||||
})
|
||||
|
||||
test('no provider for the owner shows the row', () => {
|
||||
// The no-module-installed case, and the reason the filter is a correct no-op
|
||||
// on a bare core rather than a nav that renders nothing.
|
||||
const gate = buildFeatureGate(new Map())
|
||||
assert.equal(gate({ to: '/site/atlas', feature: 'atlas' }), true)
|
||||
assert.equal(gate({ to: '/uo/atlas', feature: 'atlas', moduleId: 'uo' }), true)
|
||||
})
|
||||
|
||||
test('a provider still loading shows the row', () => {
|
||||
// useShardFlags returns null until its fetch lands. Blanking the nav on every
|
||||
// page load and filling it in a moment later is the behaviour this avoids.
|
||||
const gate = buildFeatureGate(new Map([['core', null]]))
|
||||
assert.equal(gate({ to: '/site/atlas', feature: 'atlas' }), true)
|
||||
})
|
||||
|
||||
test('a provider that returned something unusable shows the row', () => {
|
||||
for (const bad of [undefined, 42, 'atlas', {}, []]) {
|
||||
const gate = buildFeatureGate(new Map([['core', bad]]))
|
||||
assert.equal(gate({ to: '/site/atlas', feature: 'atlas' }), true, `failed closed on ${JSON.stringify(bad)}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('an array-backed provider is not silently treated as a Set', () => {
|
||||
// `[].has` does not exist, so this is the unusable case above rather than a
|
||||
// membership test that quietly always fails. Asserted so that a future
|
||||
// "helpful" normalisation knows it changed a documented behaviour.
|
||||
const gate = buildFeatureGate(new Map([['core', ['atlas']]]))
|
||||
assert.equal(gate({ to: '/site/atlas', feature: 'atlas' }), true)
|
||||
})
|
||||
|
||||
test('a missing map, or a junk row, shows rather than throws', () => {
|
||||
assert.equal(buildFeatureGate(null)({ feature: 'atlas' }), true)
|
||||
assert.equal(buildFeatureGate(new Map())(null), true)
|
||||
assert.equal(buildFeatureGate(new Map())(undefined), true)
|
||||
})
|
||||
|
||||
test('any Set-like satisfies a provider — core does not require a Set', () => {
|
||||
const gate = buildFeatureGate(new Map([['uo', { has: (name) => name === 'ruleset' }]]))
|
||||
assert.equal(gate({ feature: 'ruleset', moduleId: 'uo' }), true)
|
||||
assert.equal(gate({ feature: 'champs', moduleId: 'uo' }), false)
|
||||
})
|
||||
|
||||
test('the open gate is what a component outside the provider gets', () => {
|
||||
assert.equal(OPEN_GATE({ feature: 'anything' }), true)
|
||||
})
|
||||
187
client/test/moduleNav.test.js
Normal file
187
client/test/moduleNav.test.js
Normal file
@@ -0,0 +1,187 @@
|
||||
import { test, beforeEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { withModuleNav } from '../src/modules/nav.js'
|
||||
import { registerNav, _reset } from '../src/modules/registry.js'
|
||||
import { applyNavOverrides, buildPublicNav } from '../src/lib/navOverrides.js'
|
||||
|
||||
// The interleave of module nav rows into core's nav (MODULE_API.md §3.3, Phase 2
|
||||
// PR 8). Tested against the real merge next door rather than in isolation,
|
||||
// because the property that matters is a relationship between the two: a module
|
||||
// row has to be indistinguishable from a core row to everything downstream, and
|
||||
// the way to prove that is to run the downstream thing on it.
|
||||
|
||||
const PUBLIC = [
|
||||
{ label: 'Home', to: '/', end: true },
|
||||
{ label: 'News', to: '/site/news' },
|
||||
{ label: 'About', to: '/site/about' },
|
||||
]
|
||||
|
||||
const ADMIN = [
|
||||
{ items: [{ to: '/admin', label: 'Dashboard', end: true, roles: ['admin', 'moderator'] }] },
|
||||
{ title: 'Moderation', items: [{ to: '/admin/moderation', label: 'Moderation' }] },
|
||||
{ title: 'System', items: [{ to: '/admin/users', label: 'Users' }, { to: '/admin/settings', label: 'Settings' }] },
|
||||
{ items: [{ to: '/admin/account', label: 'Account' }] },
|
||||
]
|
||||
|
||||
beforeEach(() => _reset())
|
||||
|
||||
test('with no module installed the base array is returned unchanged', () => {
|
||||
// Identity, not a copy: this is what makes the useMemo in each layout honest,
|
||||
// and what guarantees an instance with no modules renders what it renders now.
|
||||
assert.equal(withModuleNav(PUBLIC, 'public'), PUBLIC)
|
||||
assert.equal(withModuleNav(ADMIN, 'admin'), ADMIN)
|
||||
})
|
||||
|
||||
test('a flat nav places a module row by the order it asked for', () => {
|
||||
registerNav('uo', { area: 'public', items: [{ label: 'Atlas', to: '/uo/atlas', order: 1 }] })
|
||||
assert.deepEqual(
|
||||
withModuleNav(PUBLIC, 'public').map((i) => i.label),
|
||||
['Home', 'Atlas', 'News', 'About'],
|
||||
)
|
||||
})
|
||||
|
||||
test('a flat row with no order appends rather than jumping to the front', () => {
|
||||
// The 0-default trap: `order ?? 0` would put an unordered row first, which is
|
||||
// the one place a module could take over the nav without asking for anything.
|
||||
registerNav('uo', { area: 'public', items: [{ label: 'Atlas', to: '/uo/atlas' }] })
|
||||
assert.deepEqual(
|
||||
withModuleNav(PUBLIC, 'public').map((i) => i.label),
|
||||
['Home', 'News', 'About', 'Atlas'],
|
||||
)
|
||||
})
|
||||
|
||||
test('an explicit order beats a core row that merely sits at that index', () => {
|
||||
registerNav('uo', { area: 'public', items: [{ label: 'Atlas', to: '/uo/atlas', order: 2 }] })
|
||||
const labels = withModuleNav(PUBLIC, 'public').map((i) => i.label)
|
||||
assert.deepEqual(labels, ['Home', 'News', 'Atlas', 'About'])
|
||||
})
|
||||
|
||||
test('an admin row lands INSIDE the core group it names', () => {
|
||||
registerNav('uo', {
|
||||
area: 'admin',
|
||||
items: [
|
||||
{ label: 'In-Game Ops', to: '/admin/uo/shard-ops', group: 'Moderation', order: 30 },
|
||||
{ label: 'Shard', to: '/admin/uo/link', group: 'System', order: 0 },
|
||||
],
|
||||
})
|
||||
const nav = withModuleNav(ADMIN, 'admin')
|
||||
assert.deepEqual(nav.map((g) => g.title), [undefined, 'Moderation', 'System', undefined])
|
||||
assert.deepEqual(nav[1].items.map((i) => i.label), ['Moderation', 'In-Game Ops'])
|
||||
// order 0 puts it above both core rows, which is the whole point of the field.
|
||||
assert.deepEqual(nav[2].items.map((i) => i.label), ['Shard', 'Users', 'Settings'])
|
||||
})
|
||||
|
||||
test('an unknown group appends a new group instead of dropping the row', () => {
|
||||
// A typo must cost a position, never a link.
|
||||
registerNav('uo', { area: 'admin', items: [{ label: 'Atlas', to: '/admin/uo/atlas', group: 'Moderaton' }] })
|
||||
const nav = withModuleNav(ADMIN, 'admin')
|
||||
assert.equal(nav.length, ADMIN.length + 1)
|
||||
assert.deepEqual(nav.at(-1), { title: 'Moderaton', items: [{ label: 'Atlas', to: '/admin/uo/atlas', group: 'Moderaton', moduleId: 'uo' }] })
|
||||
})
|
||||
|
||||
test('an admin row with no group gets a trailing untitled group of its own', () => {
|
||||
// NOT folded into one of core's untitled groups: those are Dashboard at the
|
||||
// top and Account at the bottom, and a module page belongs beside neither.
|
||||
registerNav('uo', { area: 'admin', items: [{ label: 'Atlas', to: '/admin/uo/atlas' }] })
|
||||
const nav = withModuleNav(ADMIN, 'admin')
|
||||
assert.equal(nav.length, ADMIN.length + 1)
|
||||
assert.equal(nav.at(-1).title, undefined)
|
||||
assert.deepEqual(nav.at(-1).items.map((i) => i.label), ['Atlas'])
|
||||
assert.deepEqual(nav[0].items.map((i) => i.label), ['Dashboard'])
|
||||
assert.deepEqual(nav[3].items.map((i) => i.label), ['Account'])
|
||||
})
|
||||
|
||||
test('a row whose `to` collides with a core row is dropped, not rendered twice', () => {
|
||||
// `to` is the key the override layer stores under and React renders by. Two
|
||||
// rows sharing one would give an admin a single editor row that moves both.
|
||||
const warnings = []
|
||||
const warn = console.warn
|
||||
console.warn = (msg) => warnings.push(msg)
|
||||
try {
|
||||
registerNav('uo', {
|
||||
area: 'public',
|
||||
items: [{ label: 'Not News', to: '/site/news' }, { label: 'Atlas', to: '/uo/atlas' }],
|
||||
})
|
||||
const nav = withModuleNav(PUBLIC, 'public')
|
||||
assert.deepEqual(nav.map((i) => i.label), ['Home', 'News', 'About', 'Atlas'])
|
||||
assert.equal(warnings.length, 1)
|
||||
assert.match(warnings[0], /\/site\/news.*collides/)
|
||||
} finally {
|
||||
console.warn = warn
|
||||
}
|
||||
})
|
||||
|
||||
test('two modules cannot claim the same path either', () => {
|
||||
const warn = console.warn
|
||||
console.warn = () => {}
|
||||
try {
|
||||
registerNav('aa', { area: 'public', items: [{ label: 'First', to: '/shared' }] })
|
||||
registerNav('zz', { area: 'public', items: [{ label: 'Second', to: '/shared' }] })
|
||||
const labels = withModuleNav(PUBLIC, 'public').map((i) => i.label)
|
||||
assert.deepEqual(labels, ['Home', 'News', 'About', 'First'])
|
||||
} finally {
|
||||
console.warn = warn
|
||||
}
|
||||
})
|
||||
|
||||
test('a module row carries its moduleId through, which is how the gate finds it', () => {
|
||||
registerNav('uo', { area: 'public', items: [{ label: 'Atlas', to: '/uo/atlas', feature: 'atlas' }] })
|
||||
const row = withModuleNav(PUBLIC, 'public').at(-1)
|
||||
assert.equal(row.moduleId, 'uo')
|
||||
assert.equal(row.feature, 'atlas')
|
||||
})
|
||||
|
||||
test('areas do not leak into one another', () => {
|
||||
registerNav('uo', { area: 'admin', items: [{ label: 'Shard', to: '/admin/uo/link', group: 'System' }] })
|
||||
assert.equal(withModuleNav(PUBLIC, 'public'), PUBLIC)
|
||||
})
|
||||
|
||||
// ── The relationship that is the actual requirement ───────────────────────
|
||||
|
||||
test('an admin override applies to a module row exactly as to a core row', () => {
|
||||
// The reason the interleave happens BEFORE the merge and not after: the merge
|
||||
// drops any key its base array does not declare, so appending module rows
|
||||
// afterwards would make every one of them unorderable, unrelabellable and
|
||||
// unhideable — a visible regression the day the UO rows leave core.
|
||||
registerNav('uo', { area: 'public', items: [{ label: 'Atlas', to: '/uo/atlas' }] })
|
||||
const base = withModuleNav(PUBLIC, 'public')
|
||||
const merged = applyNavOverrides(base, {
|
||||
'/uo/atlas': { label: 'Bestiary', order: 0 },
|
||||
'/site/news': { order: 3 },
|
||||
})
|
||||
assert.deepEqual(merged.map((i) => i.label), ['Bestiary', 'Home', 'About', 'News'])
|
||||
})
|
||||
|
||||
test('an override can hide a module row, and the public tree can section it', () => {
|
||||
registerNav('uo', { area: 'public', items: [{ label: 'Atlas', to: '/uo/atlas' }, { label: 'Market', to: '/uo/market' }] })
|
||||
const base = withModuleNav(PUBLIC, 'public')
|
||||
|
||||
const hidden = buildPublicNav(base, { '/uo/atlas': { hidden: true } })
|
||||
assert.equal(hidden.some((n) => n.to === '/uo/atlas'), false)
|
||||
|
||||
const sectioned = buildPublicNav(base, {
|
||||
items: { '/uo/market': { section: 'sec_shard' } },
|
||||
sections: [{ id: 'sec_shard', label: 'Shard', order: 0 }],
|
||||
})
|
||||
assert.equal(sectioned[0].kind, 'section')
|
||||
assert.deepEqual(sectioned[0].items.map((i) => i.to), ['/uo/market'])
|
||||
})
|
||||
|
||||
test('a module row can be moved between admin groups by an override', () => {
|
||||
registerNav('uo', { area: 'admin', items: [{ label: 'Shard', to: '/admin/uo/link', group: 'System' }] })
|
||||
const base = withModuleNav(ADMIN, 'admin')
|
||||
const merged = applyNavOverrides(base, { '/admin/uo/link': { group: 'Moderation' } })
|
||||
assert.deepEqual(merged[1].items.map((i) => i.to), ['/admin/moderation', '/admin/uo/link'])
|
||||
assert.deepEqual(merged[2].items.map((i) => i.to), ['/admin/users', '/admin/settings'])
|
||||
})
|
||||
|
||||
test('a group a module created is itself a legal override destination', () => {
|
||||
// Falls out of building the destination set from the base nav it is handed —
|
||||
// recorded because it is the kind of thing that would otherwise be discovered
|
||||
// by an admin finding a section they cannot move anything into.
|
||||
registerNav('uo', { area: 'admin', items: [{ label: 'Atlas', to: '/admin/uo/atlas', group: 'Shard' }] })
|
||||
const base = withModuleNav(ADMIN, 'admin')
|
||||
const merged = applyNavOverrides(base, { '/admin/users': { group: 'Shard' } })
|
||||
assert.deepEqual(merged.at(-1).items.map((i) => i.to), ['/admin/uo/atlas', '/admin/users'])
|
||||
})
|
||||
187
client/test/moduleRegistry.test.js
Normal file
187
client/test/moduleRegistry.test.js
Normal file
@@ -0,0 +1,187 @@
|
||||
import { test, beforeEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import {
|
||||
registry,
|
||||
registerRoutes,
|
||||
registerNav,
|
||||
registerFeatureProvider,
|
||||
routesFor,
|
||||
navFor,
|
||||
featureProviderFor,
|
||||
featureProviders,
|
||||
registeredIds,
|
||||
_reset,
|
||||
} from '../src/modules/registry.js'
|
||||
import { MODULE_API_VERSION } from '../src/modules/version.js'
|
||||
|
||||
// The client-side module registry (docs/website/MODULE_API.md §3.3). Tested in
|
||||
// isolation from React, like the nav-override merge next door, because the
|
||||
// property worth proving has nothing to do with rendering: a module gets exactly
|
||||
// the URL namespace core gave it, however it spells the paths it registers.
|
||||
//
|
||||
// window.__rg itself (modules/shared.js) is not tested here — it imports .jsx and
|
||||
// there is no DOM in this runner. What it publishes is React, the router and
|
||||
// core components: a wiring test would assert that an import statement imported
|
||||
// something. Phase 1's spike proved the half that can actually fail, which is a
|
||||
// real chunk resolving its externals against the global in a browser under an
|
||||
// enforced CSP.
|
||||
|
||||
beforeEach(() => _reset())
|
||||
|
||||
test('a module route is namespaced under the module id', () => {
|
||||
registerRoutes('uo', { public: [{ path: 'atlas', element: 'ATLAS' }] })
|
||||
assert.deepEqual(
|
||||
routesFor('public').map((r) => r.path),
|
||||
['uo/atlas'],
|
||||
)
|
||||
})
|
||||
|
||||
test('a module cannot spell its way out of its namespace', () => {
|
||||
// Whatever the module writes, the segment it lands under is core's to choose:
|
||||
// leading slashes, several of them, a trailing one, or nothing at all.
|
||||
registerRoutes('uo', {
|
||||
public: [
|
||||
{ path: '/atlas' },
|
||||
{ path: '//atlas/creatures' },
|
||||
{ path: 'atlas/' },
|
||||
{ path: '' },
|
||||
],
|
||||
})
|
||||
assert.deepEqual(
|
||||
routesFor('public').map((r) => r.path),
|
||||
['uo/atlas', 'uo/atlas/creatures', 'uo/atlas', 'uo'],
|
||||
)
|
||||
})
|
||||
|
||||
test('a path is namespaced, not sanitised — traversal stays a literal segment', () => {
|
||||
// `..` is not stripped, and does not need to be: React Router matches path
|
||||
// patterns literally, so `/uo/../admin` is a route nothing navigates to rather
|
||||
// than a route that resolves somewhere else. Asserted so that a future
|
||||
// "cleanup" that starts resolving these knows it changed a behaviour.
|
||||
registerRoutes('uo', { public: [{ path: '../admin' }] })
|
||||
assert.deepEqual(routesFor('public')[0].path, 'uo/../admin')
|
||||
})
|
||||
|
||||
test('routes keep their gate and carry the owning module id', () => {
|
||||
registerRoutes('uo', {
|
||||
admin: [{ path: 'shard-ops', element: 'OPS', gate: { roles: ['admin', 'moderator'] } }],
|
||||
})
|
||||
const [route] = routesFor('admin')
|
||||
assert.deepEqual(route.gate, { roles: ['admin', 'moderator'] })
|
||||
assert.equal(route.moduleId, 'uo')
|
||||
assert.equal(route.element, 'OPS')
|
||||
})
|
||||
|
||||
test('the three areas are kept apart', () => {
|
||||
registerRoutes('uo', {
|
||||
public: [{ path: 'atlas' }],
|
||||
admin: [{ path: 'link' }],
|
||||
player: [{ path: 'chars' }],
|
||||
})
|
||||
assert.equal(routesFor('public').length, 1)
|
||||
assert.equal(routesFor('admin').length, 1)
|
||||
assert.equal(routesFor('player').length, 1)
|
||||
// An area nobody registered is an empty list, never undefined: App.jsx maps
|
||||
// over all three unconditionally.
|
||||
_reset()
|
||||
for (const area of ['public', 'admin', 'player']) assert.deepEqual(routesFor(area), [])
|
||||
})
|
||||
|
||||
test('an unknown area throws rather than being dropped', () => {
|
||||
// Loudly, because the alternative is a module whose pages simply never appear
|
||||
// and no indication anywhere of why.
|
||||
assert.throws(() => registerRoutes('uo', { publik: [{ path: 'atlas' }] }), /unknown area/)
|
||||
assert.throws(() => registerNav('uo', { area: 'sidebar', items: [] }), /unknown area/)
|
||||
assert.equal(registeredIds().length, 0)
|
||||
})
|
||||
|
||||
test('nav items sort by order, and equal orders keep load order', () => {
|
||||
registerNav('aa', { area: 'admin', items: [{ label: 'Second', to: '/a', order: 30 }] })
|
||||
registerNav('zz', { area: 'admin', items: [{ label: 'Third', to: '/z', order: 30 }] })
|
||||
registerNav('mm', { area: 'admin', items: [{ label: 'First', to: '/m', order: 10 }] })
|
||||
assert.deepEqual(
|
||||
navFor('admin').map((i) => i.label),
|
||||
['First', 'Second', 'Third'],
|
||||
)
|
||||
})
|
||||
|
||||
test('a nav item with no order sorts after the ones that asked for a place', () => {
|
||||
registerNav('uo', {
|
||||
area: 'public',
|
||||
items: [{ label: 'Unordered', to: '/u' }, { label: 'Early', to: '/e', order: 5 }],
|
||||
})
|
||||
assert.deepEqual(
|
||||
navFor('public').map((i) => i.label),
|
||||
['Early', 'Unordered'],
|
||||
)
|
||||
})
|
||||
|
||||
test('a feature provider is stored under its namespace, with its owner', () => {
|
||||
const hook = () => ({ atlas: true })
|
||||
registerFeatureProvider('uo', 'shard', hook)
|
||||
assert.deepEqual(featureProviderFor('shard'), { id: 'uo', hook })
|
||||
assert.equal(featureProviderFor('nothing'), undefined)
|
||||
})
|
||||
|
||||
test('providers can be enumerated in registration order, with their owner', () => {
|
||||
// Core's feature context has to CALL each of these, as a hook, in a fixed
|
||||
// order — so it needs the list, and it needs the owner id to resolve a nav
|
||||
// row whose `moduleId` says who it belongs to (modules/features.jsx).
|
||||
const uo = () => null
|
||||
const rust = () => null
|
||||
registerFeatureProvider('uo', 'shard', uo)
|
||||
registerFeatureProvider('rust', 'server', rust)
|
||||
assert.deepEqual(featureProviders(), [
|
||||
{ id: 'uo', namespace: 'shard', hook: uo },
|
||||
{ id: 'rust', namespace: 'server', hook: rust },
|
||||
])
|
||||
})
|
||||
|
||||
test('enumerating providers is NOT part of the module-facing surface', () => {
|
||||
// A module asks for a namespace it knows the name of; enumerating what
|
||||
// everyone else registered is core's business, so `featureProviders` is a
|
||||
// module export and not a member of window.__rg.registry.
|
||||
assert.equal(registry.featureProviders, undefined)
|
||||
assert.equal(typeof featureProviders, 'function')
|
||||
})
|
||||
|
||||
test('every registration marks the module registered', () => {
|
||||
registerRoutes('a', { public: [{ path: 'x' }] })
|
||||
registerNav('b', { area: 'public', items: [] })
|
||||
registerFeatureProvider('c', 'ns', () => {})
|
||||
assert.deepEqual(registeredIds().sort(), ['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
test('the registry object handed to modules exposes the whole surface', () => {
|
||||
// window.__rg.registry is the ONLY way a module reaches any of this, so a
|
||||
// member missing from the object is a member that does not exist.
|
||||
assert.deepEqual(Object.keys(registry).sort(), [
|
||||
'featureProviderFor',
|
||||
'navFor',
|
||||
'registerFeatureProvider',
|
||||
'registerNav',
|
||||
'registerRoutes',
|
||||
'registeredIds',
|
||||
'routesFor',
|
||||
])
|
||||
})
|
||||
|
||||
test('the client and server halves declare the same MODULE_API_VERSION', () => {
|
||||
// The value is duplicated because it has to be on window.__rg before the first
|
||||
// module chunk evaluates, which is earlier than a fetch could answer. This is
|
||||
// the test that pays for the copy: a bump that edits one file fails here
|
||||
// instead of shipping a core whose two halves disagree about the contract they
|
||||
// implement.
|
||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||
const server = fs.readFileSync(
|
||||
path.join(here, '..', '..', 'server', 'src', 'modules', 'version.js'),
|
||||
'utf8',
|
||||
)
|
||||
const match = server.match(/MODULE_API_VERSION\s*=\s*'([^']+)'/)
|
||||
assert.ok(match, 'server/src/modules/version.js no longer declares MODULE_API_VERSION as a literal')
|
||||
assert.equal(MODULE_API_VERSION, match[1])
|
||||
})
|
||||
544
client/test/navOverrides.test.js
Normal file
544
client/test/navOverrides.test.js
Normal 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 admin’s 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' } } }), {})
|
||||
})
|
||||
28
client/test/settingsJson.test.js
Normal file
28
client/test/settingsJson.test.js
Normal 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('{}'), {})
|
||||
})
|
||||
100
client/test/themeVars.test.js
Normal file
100
client/test/themeVars.test.js
Normal 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, [])
|
||||
})
|
||||
@@ -35,6 +35,10 @@ services:
|
||||
DB_HOST: db
|
||||
UPLOAD_DIR: /app/uploads
|
||||
LOG_DIR: /app/logs
|
||||
# Where the loader scans for installed modules. Same path the code already
|
||||
# defaults to (<repo>/modules, and the repo is /app in the image), set
|
||||
# explicitly because the bind mount below is what makes it meaningful.
|
||||
MODULES_DIR: /app/modules
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
@@ -47,6 +51,25 @@ services:
|
||||
# the image, so this mount only matters for custom brand images. Create
|
||||
# ./brand/ on the host and drop assets in; read-only in the container.
|
||||
- ./brand:/app/brand:ro
|
||||
# Installed modules (docs/website/MODULE_SYSTEM.md). Modules live on a
|
||||
# mount, NEVER in the image: that is what lets an operator add one to a
|
||||
# pull-only deployment without building anything. A bind mount rather than
|
||||
# a named volume because placing a module directory by hand is a supported
|
||||
# install — `tar -xf uo-1.0.0.tgz -C ./modules` then restart — and that has
|
||||
# to be doable from the host, not through `docker cp`.
|
||||
#
|
||||
# Read-WRITE: the admin panel's install/uninstall unpacks and removes
|
||||
# directories here from inside the container.
|
||||
#
|
||||
# `modules/` is tracked (it ships a README) so the directory exists in the
|
||||
# checkout with the operator's own ownership. Do not delete it — Docker
|
||||
# would recreate a missing bind-mount source as root:root and the container
|
||||
# user could no longer write it. If the app runs as a uid that does not own
|
||||
# ./modules, `chown 1000:1000 modules` on the host.
|
||||
#
|
||||
# Adding or removing a module takes a RESTART: the scan is synchronous at
|
||||
# require time (MODULE_API.md §4.1), so nothing here is picked up live.
|
||||
- ./modules:/app/modules
|
||||
# Only the PUBLIC API port (3000) is published. The internal server<->bot
|
||||
# port (INTERNAL_PORT, default 3001) is deliberately NOT listed here, so it
|
||||
# stays reachable only over the private compose network — Pangolin/the public
|
||||
|
||||
75
modules/README.md
Normal file
75
modules/README.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Installed modules
|
||||
|
||||
This directory is bind-mounted into the container at `/app/modules` (see
|
||||
`docker-compose.yml`). It is where **installed modules** live — the game-specific
|
||||
routes, tables, screens and nav that are not part of core. Design of record:
|
||||
[`docs/website/MODULE_SYSTEM.md`](../../docs/website/MODULE_SYSTEM.md); the
|
||||
normative contract module authors build against is
|
||||
[`docs/website/MODULE_API.md`](../../docs/website/MODULE_API.md).
|
||||
|
||||
**Core ships no module.** This directory is empty in a fresh checkout, and the
|
||||
site runs cleanly that way — an empty `modules/` is the normal state for bare
|
||||
core, not a misconfiguration. Everything below is ignored by git except this
|
||||
README, which exists so the directory itself is tracked: `docker-compose.yml`
|
||||
bind-mounts it, and Docker recreates a *missing* bind-mount source as a
|
||||
root-owned directory the container user cannot write.
|
||||
|
||||
## Layout
|
||||
|
||||
One directory per module, named for its id, each holding a prebuilt bundle:
|
||||
|
||||
```
|
||||
modules/
|
||||
uo/
|
||||
module.json # the manifest the loader reads
|
||||
server/index.js # registers routes, streams, hooks
|
||||
server/db/schema.sql # tables, replayed every boot
|
||||
server/db/purge.sql # only ever run by an explicit purge
|
||||
client/dist/entry.js # prebuilt ESM chunk, served at /modules/uo/
|
||||
```
|
||||
|
||||
**Nothing here is compiled by the operator.** A module arrives already built —
|
||||
that is the whole point of the design. There is no install step that runs a
|
||||
bundler, and none that needs one.
|
||||
|
||||
## Installing a module
|
||||
|
||||
Two supported paths, both writing the same `installed_modules` row:
|
||||
|
||||
- **The admin panel** downloads the bundle from the module's release, verifies it
|
||||
against its `sha256`, and unpacks it here.
|
||||
- **By hand**, for a compose-managed host: unpack the bundle into a directory
|
||||
named for the module id, e.g. `tar -xf uo-1.0.0.tgz -C ./modules`.
|
||||
|
||||
Either way, **adding or removing a module takes a restart.** The loader scans
|
||||
this directory synchronously at startup (`MODULE_API.md` §4.1); nothing placed
|
||||
here is picked up by a running server.
|
||||
|
||||
```
|
||||
docker compose restart app
|
||||
```
|
||||
|
||||
On boot each module is validated, mounted, its schema fragment replayed and its
|
||||
`onBoot` hook run — reaching `started`, or `startup_failed` with the stage and
|
||||
reason recorded. A module that fails to start does not stop the site: core, and
|
||||
every other module, carry on without it.
|
||||
|
||||
## Uninstalling
|
||||
|
||||
Removing a directory and restarting is enough to stop a module serving. Note that
|
||||
this is *not* the same as an uninstall through the admin panel, which also marks
|
||||
the row `disabled` — a directory that simply vanishes leaves a row claiming to be
|
||||
enabled, which the loader records as `startup_failed`.
|
||||
|
||||
A module's **tables and data are retained** in both cases. Dropping them is a
|
||||
separate, explicit, destructive purge; it is never bundled into an uninstall.
|
||||
|
||||
## Ownership
|
||||
|
||||
The container runs as uid 1000 (`node`) and the admin panel writes here, so the
|
||||
app must be able to write this directory. It is created by your checkout, with
|
||||
your ownership. If they differ:
|
||||
|
||||
```
|
||||
chown -R 1000:1000 modules
|
||||
```
|
||||
@@ -359,7 +359,7 @@ CREATE TABLE IF NOT EXISTS uo_link_config (
|
||||
base_url VARCHAR(255) NULL,
|
||||
ws_url VARCHAR(255) NULL,
|
||||
auth_token_enc TEXT NULL,
|
||||
protocol INT NOT NULL DEFAULT 1,
|
||||
protocol INT NOT NULL DEFAULT 3,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'disconnected',
|
||||
status_detail VARCHAR(500) NULL,
|
||||
@@ -615,6 +615,101 @@ CREATE TABLE IF NOT EXISTS shard_ruleset (
|
||||
CONSTRAINT chk_shard_ruleset_singleton CHECK (id = 1)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Points/loyalty leaderboards (Protocol 3.0 points.board). One row per point
|
||||
-- system, keyed by the shard's own PointsType name. The shard publishes ~25 of
|
||||
-- these (Queen's Loyalty, Void Pool, the nine city loyalties, …), each a standing
|
||||
-- players accumulate over months.
|
||||
--
|
||||
-- The top-N list stays inside `payload` rather than being normalized into a
|
||||
-- shard_points_entries table. It is a fixed-size list (10 by default) that is only
|
||||
-- ever read whole, exactly like shard_governors.candidates — normalizing it would
|
||||
-- buy nothing until something needs a per-character reverse lookup, and a
|
||||
-- character's own standings already ride inside char.profile instead.
|
||||
--
|
||||
-- No delete path: the shard's set of systems is fixed at startup, so there is no
|
||||
-- points.remove to mirror.
|
||||
CREATE TABLE IF NOT EXISTS shard_points_boards (
|
||||
system VARCHAR(48) PRIMARY KEY, -- PointsType name, e.g. QueensLoyalty
|
||||
name VARCHAR(128) NULL, -- resolved display name, if the shard sent a literal
|
||||
name_cliloc INT NULL, -- cliloc id when the name is a TextDefinition number
|
||||
max_points BIGINT NULL,
|
||||
players INT NULL, -- players actually holding points in this system
|
||||
show_on_gump TINYINT(1) NOT NULL DEFAULT 1, -- the shard's own "is this player-facing?" flag
|
||||
payload JSON NOT NULL, -- the whole points.board frame, incl. `top`
|
||||
t BIGINT NULL, -- frame time, epoch ms
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Player-vendor market index (Protocol 3.0 vendor.listing). One row per player
|
||||
-- vendor and one per priced listing, so the site can offer the search the in-game
|
||||
-- Vendor Search gump offers — from outside the game.
|
||||
--
|
||||
-- The shard sweeps vendors round-robin and emits one AUTHORITATIVE frame per
|
||||
-- vendor, so ingest is delete-then-insert of that vendor's items inside one
|
||||
-- transaction (see shardMarket.db.js). No foreign key from items to vendors, in
|
||||
-- keeping with every other shard_* table: the ingest transaction is what keeps
|
||||
-- them consistent, and an FK would turn a malformed frame into a failed write
|
||||
-- rather than a dropped row.
|
||||
--
|
||||
-- Only vendors whose owner left the in-game Vendor Search flag ON are ever sent,
|
||||
-- so a player who hid their shop in game is hidden here too — see BridgeMarket.cs.
|
||||
CREATE TABLE IF NOT EXISTS shard_vendors (
|
||||
serial VARCHAR(20) NOT NULL PRIMARY KEY, -- "0x40001234"
|
||||
shop_name VARCHAR(160) NULL,
|
||||
owner_serial VARCHAR(20) NULL,
|
||||
owner_name VARCHAR(64) NULL,
|
||||
map VARCHAR(40) NULL,
|
||||
x INT NULL,
|
||||
y INT NULL,
|
||||
z INT NULL,
|
||||
region VARCHAR(80) NULL,
|
||||
house VARCHAR(160) NULL, -- the house SIGN's name, not the house type
|
||||
item_count INT NOT NULL DEFAULT 0, -- listings published in the frame
|
||||
item_total INT NOT NULL DEFAULT 0, -- listings the shop actually holds
|
||||
truncated TINYINT(1) NOT NULL DEFAULT 0, -- item_total > item_count
|
||||
t BIGINT NULL, -- frame time, epoch ms
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_shard_vendors_owner (owner_name),
|
||||
INDEX idx_shard_vendors_map (map),
|
||||
INDEX idx_shard_vendors_region (region),
|
||||
-- The market page's staleness banner is MIN(updated_at) over this column: the
|
||||
-- round-robin sweep means the oldest row is how far behind the index can be.
|
||||
INDEX idx_shard_vendors_updated (updated_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- One priced listing. Unlike the points board's top-N — a fixed-size list read
|
||||
-- whole — these are the searchable rows the whole feature exists for, so they are
|
||||
-- normalized rather than left inside a payload column, and there is no payload
|
||||
-- column on shard_vendors at all.
|
||||
--
|
||||
-- `display_name` is DENORMALIZED at ingest: the shard sends `cliloc` (the item's
|
||||
-- LabelNumber) and, rarely, a literal `name`, and resolving 50 clilocs per page
|
||||
-- at query time would make the cliloc table a join on the hot path AND make
|
||||
-- search-by-name impossible. Resolving once on write buys the index. It is
|
||||
-- re-resolved in bulk after a cliloc import, because the diff sweep will not
|
||||
-- re-send an unchanged shop just because the site learned what its items are
|
||||
-- called.
|
||||
CREATE TABLE IF NOT EXISTS shard_vendor_items (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
vendor_serial VARCHAR(20) NOT NULL,
|
||||
serial VARCHAR(20) NOT NULL,
|
||||
item_id INT NOT NULL DEFAULT 0, -- ItemID (the art/graphic id)
|
||||
hue INT NOT NULL DEFAULT 0,
|
||||
amount INT NOT NULL DEFAULT 1,
|
||||
price BIGINT NOT NULL DEFAULT 0,
|
||||
name VARCHAR(160) NULL, -- the item's literal Name, null for most
|
||||
cliloc INT NULL, -- LabelNumber, resolved against shard_clilocs
|
||||
display_name VARCHAR(160) NULL, -- resolved at ingest; what search matches
|
||||
child TINYINT(1) NOT NULL DEFAULT 0, -- priced by an enclosing container, not itself
|
||||
INDEX idx_shard_vendor_items_vendor (vendor_serial),
|
||||
INDEX idx_shard_vendor_items_price (price),
|
||||
INDEX idx_shard_vendor_items_item (item_id),
|
||||
INDEX idx_shard_vendor_items_name (display_name),
|
||||
-- Search filters on name and sorts on price; the composite covers the common
|
||||
-- "cheapest matching X" without a filesort over the whole table.
|
||||
INDEX idx_shard_vendor_items_name_price (display_name, price)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Per-feature visibility for every shard-derived surface (Protocol 3.0). One row
|
||||
-- per feature; an absent row means "use the compiled default", and the compiled
|
||||
-- defaults reproduce the behavior that shipped before v3 — so an empty table is
|
||||
@@ -1011,35 +1106,79 @@ CREATE TABLE IF NOT EXISTS pages (
|
||||
|
||||
-- Announcement pipeline. One row per publish event of a news post; the table
|
||||
-- doubles as the job queue (a light in-process poller — utils/announceWorker.js
|
||||
-- — sweeps it for due legs). Two INDEPENDENT delivery legs so a Discord outage
|
||||
-- never blocks or retries the in-game town-crier leg and vice versa. `status` is
|
||||
-- a derived rollup of the two legs (see announceJobs.logic.js): done when both
|
||||
-- legs done, failed when both exhausted, partial in between. Each leg tracks its
|
||||
-- own attempt count, last error, and next-due time for exponential backoff.
|
||||
-- post_id is INT (matches posts.id) and cascades so deleting a post reaps its
|
||||
-- jobs. posts.announce_job_id points back at the latest row for admin lookups.
|
||||
-- — sweeps it for due legs). `status` is a derived rollup of the legs (see
|
||||
-- announceJobs.logic.js): done when every leg is done, failed when every leg is
|
||||
-- exhausted, partial in between. post_id is INT (matches posts.id) and cascades
|
||||
-- so deleting a post reaps its jobs. posts.announce_job_id points back at the
|
||||
-- latest row for admin lookups.
|
||||
CREATE TABLE IF NOT EXISTS announce_jobs (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
post_id INT NOT NULL,
|
||||
status ENUM('pending','partial','done','failed') NOT NULL DEFAULT 'pending',
|
||||
|
||||
towncrier_status ENUM('pending','done','failed') NOT NULL DEFAULT 'pending',
|
||||
towncrier_attempts SMALLINT NOT NULL DEFAULT 0,
|
||||
towncrier_last_error TEXT NULL,
|
||||
towncrier_next_attempt_at DATETIME NULL,
|
||||
|
||||
discord_status ENUM('pending','done','failed') NOT NULL DEFAULT 'pending',
|
||||
discord_attempts SMALLINT NOT NULL DEFAULT 0,
|
||||
discord_last_error TEXT NULL,
|
||||
discord_next_attempt_at DATETIME NULL,
|
||||
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_announce_jobs_post FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
|
||||
INDEX idx_announce_due (towncrier_status, towncrier_next_attempt_at),
|
||||
INDEX idx_announce_due_discord (discord_status, discord_next_attempt_at)
|
||||
CONSTRAINT fk_announce_jobs_post FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- One row per delivery leg per job. INDEPENDENT by design: a Discord outage never
|
||||
-- blocks or retries another leg, and each leg tracks its own attempt count, last
|
||||
-- error and next-due time for exponential backoff.
|
||||
--
|
||||
-- This is a child table rather than a pair of leg-prefixed column groups on
|
||||
-- announce_jobs because the leg set is DATA now, not schema: core registers
|
||||
-- `discord`, module-uo registers `towncrier`, and a module for another game
|
||||
-- registers its own — through modules/registries.js's registerAnnounceLeg
|
||||
-- (MODULE_SYSTEM.md §1.8). A module cannot ALTER a core table, so a leg that
|
||||
-- needed its own columns could never come from a module at all. `leg` is a plain
|
||||
-- VARCHAR and not an ENUM for the same reason.
|
||||
CREATE TABLE IF NOT EXISTS announce_job_legs (
|
||||
job_id INT NOT NULL,
|
||||
leg VARCHAR(64) NOT NULL,
|
||||
status ENUM('pending','done','failed') NOT NULL DEFAULT 'pending',
|
||||
attempts SMALLINT NOT NULL DEFAULT 0,
|
||||
last_error TEXT NULL,
|
||||
next_attempt_at DATETIME NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (job_id, leg),
|
||||
CONSTRAINT fk_announce_job_legs_job FOREIGN KEY (job_id) REFERENCES announce_jobs(id) ON DELETE CASCADE,
|
||||
INDEX idx_announce_leg_due (status, next_attempt_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Carry the two hardcoded leg column groups over to the child table, once. Guarded
|
||||
-- on the OLD columns still existing (via information_schema, since a plain SELECT
|
||||
-- of a dropped column is a parse error, not a runtime one) and on there being no
|
||||
-- row already, so replaying this file on every boot is a no-op after the first.
|
||||
-- Deleting this block once every deployment has booted it is safe.
|
||||
SET @has_legacy_legs := (
|
||||
SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'announce_jobs'
|
||||
AND COLUMN_NAME = 'towncrier_status'
|
||||
);
|
||||
SET @sql := IF(@has_legacy_legs > 0,
|
||||
'INSERT IGNORE INTO announce_job_legs (job_id, leg, status, attempts, last_error, next_attempt_at)
|
||||
SELECT id, ''towncrier'', towncrier_status, towncrier_attempts, towncrier_last_error, towncrier_next_attempt_at FROM announce_jobs
|
||||
UNION ALL
|
||||
SELECT id, ''discord'', discord_status, discord_attempts, discord_last_error, discord_next_attempt_at FROM announce_jobs',
|
||||
'DO 0');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- MariaDB's IF EXISTS makes this idempotent, so it replays cleanly like the rest
|
||||
-- of the file. It is the one DROP in core's schema, and it is deliberate: leaving
|
||||
-- the columns would leave `towncrier` in a core file, which Phase 3's acceptance
|
||||
-- grep forbids (MODULE_SYSTEM.md §2.7).
|
||||
ALTER TABLE announce_jobs
|
||||
DROP COLUMN IF EXISTS towncrier_status,
|
||||
DROP COLUMN IF EXISTS towncrier_attempts,
|
||||
DROP COLUMN IF EXISTS towncrier_last_error,
|
||||
DROP COLUMN IF EXISTS towncrier_next_attempt_at,
|
||||
DROP COLUMN IF EXISTS discord_status,
|
||||
DROP COLUMN IF EXISTS discord_attempts,
|
||||
DROP COLUMN IF EXISTS discord_last_error,
|
||||
DROP COLUMN IF EXISTS discord_next_attempt_at,
|
||||
DROP INDEX IF EXISTS idx_announce_due,
|
||||
DROP INDEX IF EXISTS idx_announce_due_discord;
|
||||
|
||||
-- ── Spawn atlas (Protocol 3.0 Part C) ───────────────────────────────────────
|
||||
-- Static shard CONTENT, not live shard state: what spawns where, which regions
|
||||
-- and landmarks exist, and which champion altars are configured. Nothing here
|
||||
@@ -1155,6 +1294,39 @@ CREATE TABLE IF NOT EXISTS shard_champion_spawns (
|
||||
INDEX idx_shard_champion_spawns_facet (facet)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- UO's localization table: cliloc id -> display string. Items carry a
|
||||
-- `LabelNumber` rather than a name, so without this the site can only render
|
||||
-- `id 1023721` where the game shows "quarter staff". The shard has always sent
|
||||
-- the id (char.profile's `cliloc`, and one per marketplace listing) — the number
|
||||
-- was never the missing piece, the table was.
|
||||
--
|
||||
-- Sourced from a file the OPERATOR converts once from their own UO client and
|
||||
-- points the site at (docs/website/CLILOCS.md); nothing derived from the client
|
||||
-- is committed, the same rule the spawn atlas and the creature art map follow.
|
||||
-- A shard with no cliloc file configured simply renders item ids, which is what
|
||||
-- it did before this table existed.
|
||||
--
|
||||
-- `text` is TEXT, not VARCHAR: real tables top out around 12 KB for the long
|
||||
-- property descriptions, and truncating them silently would be worse than
|
||||
-- storing them. Item NAMES are all short — the index that matters for search is
|
||||
-- on the denormalized `shard_vendor_items.display_name`, not here.
|
||||
CREATE TABLE IF NOT EXISTS shard_clilocs (
|
||||
number INT NOT NULL PRIMARY KEY,
|
||||
flag SMALLINT NOT NULL DEFAULT 0,
|
||||
text TEXT NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Singleton (id = 1) describing the cliloc table currently loaded: the source
|
||||
-- file, its sha256, the entry count and the parser version. The boot path
|
||||
-- compares the stored hash against the file on disk and skips the parse when
|
||||
-- they match, which is every restart that did not follow a client patch.
|
||||
CREATE TABLE IF NOT EXISTS shard_cliloc_meta (
|
||||
id TINYINT NOT NULL PRIMARY KEY DEFAULT 1,
|
||||
payload JSON NOT NULL,
|
||||
imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_shard_cliloc_meta_singleton CHECK (id = 1)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Singleton (id = 1) describing the artifact currently loaded: when it was
|
||||
-- built, its counts, and a sha256 per ServUO source file. The admin drift check
|
||||
-- compares this against db/data/spawnAtlas.meta.json to report when the database
|
||||
@@ -1190,6 +1362,49 @@ CREATE TABLE IF NOT EXISTS shard_atlas_pending (
|
||||
CONSTRAINT chk_shard_atlas_pending_singleton CHECK (id = 1)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Installed modules (module system, docs/website/MODULE_SYSTEM.md §2.4). One row
|
||||
-- per module the operator has installed onto the modules volume, keyed by the
|
||||
-- module id from its module.json — the same id that names the directory, the URL
|
||||
-- segment and the client registry key.
|
||||
--
|
||||
-- This table is a RECORD of what happened, never the source of truth for what is
|
||||
-- mounted: the loader scans the filesystem at require time, before the database is
|
||||
-- reachable (MODULE_API.md §4.1), so the URL surface is a property of the volume
|
||||
-- and not of a row here. What the row decides is whether a mounted module answers
|
||||
-- (`disabled` ⇒ its guard 404s, §4.5) and what the admin panel shows after a
|
||||
-- failure.
|
||||
--
|
||||
-- `state` is the §2.4 machine in one column: installed → enabled → started, with
|
||||
-- disabled and startup_failed as the recoverable states. `installed` is the
|
||||
-- transient state between an install writing the row and the restart that starts
|
||||
-- it. On every boot each non-disabled row is reset to `enabled` and re-attempted
|
||||
-- (so a fixed module recovers on restart, with no panel visit needed), then the
|
||||
-- load outcome writes `started` or `startup_failed`. Only `disabled` survives a
|
||||
-- boot untouched — it is the operator's decision, not an outcome.
|
||||
--
|
||||
-- failure_stage/failure_reason are §4.4's recorded reason, one of the seven
|
||||
-- validation steps of §4.3 plus `boot`. Both are cleared by every transition that
|
||||
-- is not a failure, so a stale reason can never be shown against a running module.
|
||||
--
|
||||
-- source/sha256 are install provenance (§2.5): the release the bundle came from and
|
||||
-- the digest that was verified before unpacking. Both NULL for a directory placed
|
||||
-- on the volume by hand, which stays supported.
|
||||
CREATE TABLE IF NOT EXISTS installed_modules (
|
||||
id VARCHAR(32) NOT NULL PRIMARY KEY, -- module.json id; names the directory
|
||||
name VARCHAR(128) NOT NULL, -- human label for the admin Modules screen
|
||||
version VARCHAR(32) NOT NULL, -- module.json version (semver)
|
||||
state ENUM('installed','enabled','disabled','started','startup_failed')
|
||||
NOT NULL DEFAULT 'installed',
|
||||
failure_stage VARCHAR(32) NULL, -- manifest|core_api|mounts|extensions|schema|require|register|boot
|
||||
failure_reason TEXT NULL, -- the recorded reason, shown in the admin panel
|
||||
source VARCHAR(255) NULL, -- release URL the bundle came from
|
||||
sha256 CHAR(64) NULL, -- verified bundle digest
|
||||
installed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
started_at DATETIME NULL, -- last successful start
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_installed_modules_state (state)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Migrations for databases created before the wiki upgrade. Each statement uses
|
||||
-- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get
|
||||
-- these columns from the CREATE TABLE above; existing installs get them here.
|
||||
@@ -1269,3 +1484,19 @@ ALTER TABLE mobile_refresh_tokens ADD COLUMN IF NOT EXISTS last_used_at DATETIME
|
||||
-- trust token. A boolean only — the token is returned over that app→server call
|
||||
-- and never persisted here (only its sha256 lands in trusted_devices).
|
||||
ALTER TABLE mobile_auth_sessions ADD COLUMN IF NOT EXISTS trust_device TINYINT(1) NOT NULL DEFAULT 0;
|
||||
|
||||
-- Protocol 3.0 cutover: this build speaks wire protocol 3 (world.ruleset,
|
||||
-- points.board, vendor.listing), so the pinned version an existing install
|
||||
-- carries has to move with it — a 2 against a v3 sidecar 409s every REST call
|
||||
-- and closes the WS on ws.hello. MODIFY fixes the column default for installs
|
||||
-- created before the bump (idempotent, like the other MODIFYs here).
|
||||
ALTER TABLE uo_link_config MODIFY COLUMN protocol INT NOT NULL DEFAULT 3;
|
||||
-- The row itself is admin-editable, and schema.sql runs on EVERY boot, so this
|
||||
-- must be one-shot: an operator who deliberately pins an older sidecar in
|
||||
-- Admin → Shard has to stay pinned. The marker row in `settings` is what makes
|
||||
-- it fire once — written after the UPDATE, and on a fresh install (no
|
||||
-- uo_link_config row yet) it is simply written with nothing to update.
|
||||
UPDATE uo_link_config SET protocol = 3
|
||||
WHERE id = 1 AND protocol < 3
|
||||
AND NOT EXISTS (SELECT 1 FROM settings WHERE `key` = 'uo_link_protocol_3_migrated');
|
||||
INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_3_migrated', '1');
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"swagger": "node swagger/swagger.js",
|
||||
"routes:manifest": "node scripts/routeManifest.js",
|
||||
"atlas:import": "node scripts/importSpawnAtlas.js",
|
||||
"test": "node --test"
|
||||
"test": "node --test --require ./test/_setup.js"
|
||||
},
|
||||
"keywords": [
|
||||
"express",
|
||||
|
||||
@@ -616,6 +616,25 @@
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/settings/:key",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/settings/brand-asset/:slot",
|
||||
"handlers": 3,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"multerMiddleware"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/account",
|
||||
@@ -636,6 +655,55 @@
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/shard/atlas",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/atlas/approve",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/atlas/import",
|
||||
"handlers": 4,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/shard/atlas/path",
|
||||
"handlers": 4,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/atlas/reject",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/shard/audit",
|
||||
@@ -678,6 +746,37 @@
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/shard/clilocs",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/clilocs/import",
|
||||
"handlers": 5,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/shard/clilocs/path",
|
||||
"handlers": 4,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/shard/houses",
|
||||
@@ -963,7 +1062,7 @@
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/users/:id/shard/link/:account",
|
||||
"handlers": 5,
|
||||
"handlers": 4,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
@@ -1781,6 +1880,64 @@
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/atlas/champions",
|
||||
"handlers": 5,
|
||||
"gates": [
|
||||
"middleware",
|
||||
"validate",
|
||||
"siteMode"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/atlas/creatures",
|
||||
"handlers": 8,
|
||||
"gates": [
|
||||
"middleware",
|
||||
"validate",
|
||||
"siteMode"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/atlas/creatures/:slug",
|
||||
"handlers": 7,
|
||||
"gates": [
|
||||
"middleware",
|
||||
"validate",
|
||||
"siteMode"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/atlas/landmarks",
|
||||
"handlers": 6,
|
||||
"gates": [
|
||||
"middleware",
|
||||
"validate",
|
||||
"siteMode"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/atlas/meta",
|
||||
"handlers": 3,
|
||||
"gates": [
|
||||
"siteMode"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/atlas/regions",
|
||||
"handlers": 6,
|
||||
"gates": [
|
||||
"middleware",
|
||||
"validate",
|
||||
"siteMode"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/public/contact",
|
||||
@@ -1790,6 +1947,12 @@
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/modules",
|
||||
"handlers": 1,
|
||||
"gates": []
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/pages/:id/preview/:token",
|
||||
@@ -1889,12 +2052,48 @@
|
||||
"handlers": 2,
|
||||
"gates": []
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/market",
|
||||
"handlers": 13,
|
||||
"gates": [
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/market/meta",
|
||||
"handlers": 2,
|
||||
"gates": []
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/market/vendors/:serial",
|
||||
"handlers": 7,
|
||||
"gates": [
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/online",
|
||||
"handlers": 2,
|
||||
"gates": []
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/points",
|
||||
"handlers": 2,
|
||||
"gates": []
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/points/:system",
|
||||
"handlers": 2,
|
||||
"gates": []
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/presence",
|
||||
@@ -1962,6 +2161,24 @@
|
||||
"gates": [
|
||||
"siteMode"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/settings/nav",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/settings/theme/options",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
}
|
||||
],
|
||||
"internal": [
|
||||
|
||||
@@ -249,6 +249,14 @@
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/settings"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/settings/:key"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/settings/brand-asset/:slot"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/account"
|
||||
@@ -257,6 +265,26 @@
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/shard/accounts"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/shard/atlas"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/atlas/approve"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/atlas/import"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/shard/atlas/path"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/atlas/reject"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/shard/audit"
|
||||
@@ -273,6 +301,18 @@
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/shard/char/:serial"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/shard/clilocs"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/clilocs/import"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/shard/clilocs/path"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/shard/houses"
|
||||
@@ -713,10 +753,38 @@
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/shard/vendors/:account"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/atlas/champions"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/atlas/creatures"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/atlas/creatures/:slug"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/atlas/landmarks"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/atlas/meta"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/atlas/regions"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/public/contact"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/modules"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/pages/:id/preview/:token"
|
||||
@@ -773,10 +841,30 @@
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/idoc"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/market"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/market/meta"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/market/vendors/:serial"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/online"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/points"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/points/:system"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/presence"
|
||||
@@ -816,6 +904,14 @@
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/wiki/tags"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/settings/nav"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/settings/theme/options"
|
||||
}
|
||||
],
|
||||
"internal": [
|
||||
|
||||
@@ -16,10 +16,11 @@
|
||||
* derived (only annotated routes appear) and documents intent; this records reality.
|
||||
*
|
||||
* Scope: only `/api/**` and `/.well-known/**` from the public app, plus everything
|
||||
* on the internal app. Three mounts in app.js are *filesystem* conditional — the SPA
|
||||
* catch-all `GET *`, the `/brand` static mount and swagger-ui's `/api/docs` static
|
||||
* assets — so including them would make the output depend on whether CI had built
|
||||
* the client. Static mounts are not API contract.
|
||||
* on the internal app. Four mounts in app.js are *filesystem* conditional — the SPA
|
||||
* catch-all `GET *`, the `/brand` static mount, installed modules' `/modules/<id>`
|
||||
* chunks and swagger-ui's `/api/docs` static assets — so including them would make
|
||||
* the output depend on whether CI had built the client, or on which modules were
|
||||
* mounted. Static mounts are not API contract.
|
||||
*
|
||||
* Usage:
|
||||
* npm run routes:manifest # write server/routes.manifest.json (+ guards)
|
||||
@@ -56,7 +57,8 @@ const GUARDS_COMMENT =
|
||||
'`npm run routes:manifest`.'
|
||||
|
||||
// Only these prefixes are contract. Everything else the public app serves (SPA
|
||||
// shell, /uploads, /brand, swagger-ui assets) is static delivery, not API surface.
|
||||
// shell, /uploads, /brand, /modules, swagger-ui assets) is static delivery, not
|
||||
// API surface.
|
||||
const PUBLIC_PREFIXES = ['/api/', '/.well-known/']
|
||||
|
||||
/**
|
||||
@@ -64,9 +66,16 @@ const PUBLIC_PREFIXES = ['/api/', '/.well-known/']
|
||||
*
|
||||
* Express keeps no copy of the mount string, only the compiled regexp. For a
|
||||
* literal mount (`/api/v1`) that is `^\/api\/v1\/?(?=\/|$)`; a parameterised mount
|
||||
* contributes one `(?:([^\/]+?))` group per entry in `layer.keys`. Unwinding both
|
||||
* gets us back to `/api/v1` and `/thing/:id` respectively. `fast_slash` is
|
||||
* express's marker for a router mounted at the root, which contributes nothing.
|
||||
* contributes one group per entry in `layer.keys`, and the separator before the
|
||||
* parameter lives INSIDE that group — express 4.22 compiles `use('/:id', r)` to
|
||||
* `^(?:\/([^/]+?))\/?(?=\/|$)`. Unwinding both gets us back to `/api/v1` and
|
||||
* `/:id` respectively. `fast_slash` is express's marker for a router mounted at
|
||||
* the root, which contributes nothing.
|
||||
*
|
||||
* The parameterised branch went unexercised until the `admin.users.detail`
|
||||
* extension slot mounted a router at `/:id` (MODULE_SYSTEM.md §1.9), and it was
|
||||
* wrong: it expected the group as `(?:([^\/]+?))`, with the slash outside and the
|
||||
* class escaped. It threw rather than guessing, which is exactly what it is for.
|
||||
*/
|
||||
function mountPath(layer) {
|
||||
const re = layer.regexp
|
||||
@@ -79,9 +88,11 @@ function mountPath(layer) {
|
||||
|
||||
const keys = layer.keys || []
|
||||
let i = 0
|
||||
src = src.replace(/\(\?:\(\[\^\\\/\]\+\?\)\)/g, () => {
|
||||
// `\/` optional and the `/` in the class optionally escaped, so this survives a
|
||||
// path-to-regexp that emits either shape.
|
||||
src = src.replace(/\((?:\?:)?(\\\/)?\(\[\^\\?\/\]\+\?\)\)/g, (_m, slash) => {
|
||||
const key = keys[i++]
|
||||
return key ? `:${key.name}` : ':param'
|
||||
return `${slash ? '/' : ''}:${key ? key.name : 'param'}`
|
||||
})
|
||||
|
||||
// Whatever is left should be a literal path with regexp-escaped separators.
|
||||
|
||||
@@ -10,12 +10,15 @@ require('dotenv').config()
|
||||
const swaggerUi = require('swagger-ui-express')
|
||||
|
||||
const apiRouter = require('./router/api.router')
|
||||
const modules = require('./modules/loader')
|
||||
const registries = require('./modules/registries')
|
||||
const wellKnown = require('./router/wellKnown.controller')
|
||||
const cspReport = require('./router/cspReport.controller')
|
||||
const brand = require('./config/brand')
|
||||
const csp = require('./config/csp')
|
||||
const { cspReportLimiter } = require('./middleware/rateLimit')
|
||||
const createLogger = require('./utils/logger')
|
||||
const htmlShell = require('./utils/htmlShell')
|
||||
const { applyTrustProxy, trustProxyDebug } = require('./utils/trustProxy')
|
||||
const botScore = require('./middleware/botScore')
|
||||
|
||||
@@ -96,31 +99,6 @@ const htmlEscape = (s) =>
|
||||
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]),
|
||||
)
|
||||
|
||||
// Template the built index.html <head> with instance branding (title, meta
|
||||
// description, Open Graph/Twitter, favicon). Done once at boot from BRAND_* env,
|
||||
// so the prebuilt SPA image serves per-instance metadata without a rebuild.
|
||||
function renderIndexHtml(html) {
|
||||
const title = htmlEscape(brand.name)
|
||||
const desc = htmlEscape(brand.description)
|
||||
const tags = [
|
||||
`<meta property="og:title" content="${title}" />`,
|
||||
`<meta property="og:description" content="${desc}" />`,
|
||||
'<meta property="og:type" content="website" />',
|
||||
brand.url ? `<meta property="og:url" content="${htmlEscape(brand.url)}" />` : '',
|
||||
brand.logo ? `<meta property="og:image" content="${htmlEscape(brand.logo)}" />` : '',
|
||||
'<meta name="twitter:card" content="summary_large_image" />',
|
||||
`<meta name="twitter:title" content="${title}" />`,
|
||||
`<meta name="twitter:description" content="${desc}" />`,
|
||||
brand.favicon ? `<link rel="icon" href="${htmlEscape(brand.favicon)}" />` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n ')
|
||||
return html
|
||||
.replace(/<title>[\s\S]*?<\/title>/i, `<title>${title}</title>`)
|
||||
.replace(/(<meta\s+name="description"\s+content=")[\s\S]*?("\s*\/?>)/i, `$1${desc}$2`)
|
||||
.replace(/<\/head>/i, ` ${tags}\n </head>`)
|
||||
}
|
||||
|
||||
// Uploaded images — always served, even during maintenance. Force nosniff so a
|
||||
// stored file is never interpreted as anything other than its declared type
|
||||
// (defense in depth alongside helmet's global X-Content-Type-Options, and in
|
||||
@@ -178,8 +156,77 @@ app.get(
|
||||
app.post(csp.REPORT_PATH, cspReportLimiter, ...cspReport.parsers, cspReport.receive)
|
||||
|
||||
app.use('/api', apiRouter)
|
||||
|
||||
// ── Installed modules ─────────────────────────────────────────────────
|
||||
// Discover, validate and mount whatever is on the modules volume
|
||||
// (docs/website/MODULE_API.md Part 4). One explicit call, here and nowhere else:
|
||||
// the loader has no lazy self-scan, so there is exactly one place that decides
|
||||
// when modules are discovered, and reading the module list before this line is
|
||||
// an error rather than a silent empty answer (§7.6).
|
||||
//
|
||||
// Position is load-bearing, in both directions. It is AFTER `/api` is mounted,
|
||||
// so every core prefix is already on the tier routers when the collision check
|
||||
// asks them what core owns — and so first-match-wins means a module physically
|
||||
// cannot shadow a core route. It is BEFORE the `/api` 404 below, so a module
|
||||
// route reaches its handler instead of the catch-all.
|
||||
//
|
||||
// The three requires resolve from cache to the very routers v1.router.js
|
||||
// mounted; this is a reference to them, not a second copy.
|
||||
//
|
||||
// registerCore() first, and for the same reason the loader runs after `/api`: a
|
||||
// module's collision checks are asked against what is ALREADY registered, so
|
||||
// core's streams, its announce leg and its extension-slot fill have to be there
|
||||
// before the first module registers anything (MODULE_SYSTEM.md §1.8).
|
||||
registries.registerCore()
|
||||
modules.load({
|
||||
public: require('./router/v1/public'),
|
||||
admin: require('./router/v1/admin'),
|
||||
player: require('./router/v1/player'),
|
||||
})
|
||||
|
||||
app.use('/api', (req, res) => res.status(404).json({ message: 'Not found' }))
|
||||
|
||||
// Installed modules' prebuilt client chunks, at /modules/<id>/ — same-origin, so
|
||||
// `script-src 'self'` admits them with no nonce and no inline script
|
||||
// (docs/website/MODULE_API.md §3.1). Three properties, each load-bearing:
|
||||
//
|
||||
// • The static root is the directory the ENTRY sits in, never the module root.
|
||||
// One express.static over a module root would publish its server source, its
|
||||
// module.json and its schema fragment; the loader rejects an entry that would
|
||||
// make those the same directory.
|
||||
// • Behind the module's own state guard, so a failed module's chunk is 503 and
|
||||
// a disabled one's is 404 — the same answers its API gives, for the same
|
||||
// reason: the browser should not be running the client half of something the
|
||||
// server half has stopped serving.
|
||||
// • `fallthrough: false`, so a missing file is a 404 here rather than falling
|
||||
// through to the SPA catch-all and answering a `<script src>` with the index
|
||||
// shell, which the browser then rejects on its MIME type instead.
|
||||
//
|
||||
// Vite's library build emits an unhashed `entry.js`, so `no-cache` (revalidate,
|
||||
// not "do not store") is what stops an upgraded module serving yesterday's chunk
|
||||
// out of the disk cache.
|
||||
for (const chunk of modules.clientChunks()) {
|
||||
app.use(
|
||||
chunk.url,
|
||||
chunk.guard,
|
||||
express.static(chunk.dir, {
|
||||
fallthrough: false,
|
||||
setHeaders: (res) => {
|
||||
res.set('Cache-Control', 'no-cache')
|
||||
res.set('X-Content-Type-Options', 'nosniff')
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// Everything else under /modules is a 404, not the SPA shell. The namespace
|
||||
// belongs to installed modules' chunks — an unknown module id or a file a module
|
||||
// does not ship is a missing file, and answering a `<script src>` with an HTML
|
||||
// page turns that into a MIME-type refusal in the console with a 200 in the
|
||||
// network tab. It also keeps the namespace's boundary a fact of the app rather
|
||||
// than of whichever catch-all happens to be mounted after it.
|
||||
app.use('/modules', (req, res) => res.status(404).json({ message: 'Not found' }))
|
||||
|
||||
// ── /.well-known ──────────────────────────────────────────────────────
|
||||
// Android App Links verification file at the web root (M9 follow-up). Mounted
|
||||
// before the SPA catch-all so it returns JSON, not the index shell. 404s unless
|
||||
@@ -204,9 +251,23 @@ if (fs.existsSync(BRAND_DIR)) {
|
||||
if (fs.existsSync(path.join(CLIENT_DIST, 'index.html'))) {
|
||||
// Serve a branded copy of the index.html shell for every SPA route; assets keep
|
||||
// their own cache-friendly static handler.
|
||||
const indexHtml = renderIndexHtml(fs.readFileSync(path.join(CLIENT_DIST, 'index.html'), 'utf8'))
|
||||
//
|
||||
// The shell is templated from BRAND_* env *and* the admin's brand_assets /
|
||||
// theme_visual rows, so it is rendered lazily and cached rather than built once
|
||||
// at boot: see utils/htmlShell.js for the caching, the invalidation and why a
|
||||
// DB fault still serves a page.
|
||||
htmlShell.init(fs.readFileSync(path.join(CLIENT_DIST, 'index.html'), 'utf8'))
|
||||
app.use(express.static(CLIENT_DIST, { index: false }))
|
||||
app.get('*', (req, res) => res.type('html').send(indexHtml))
|
||||
app.get('*', async (req, res, next) => {
|
||||
// htmlShell.get() swallows a settings-read failure itself; the try is for
|
||||
// anything unforeseen, since an async handler that rejects in Express 4
|
||||
// hangs the request instead of reaching the error handler below.
|
||||
try {
|
||||
res.type('html').send(await htmlShell.get())
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
app.get('*', (req, res) =>
|
||||
res
|
||||
|
||||
26
server/src/config/coreStreams.js
Normal file
26
server/src/config/coreStreams.js
Normal file
@@ -0,0 +1,26 @@
|
||||
// ── Core's own push-notification streams ───────────────────────────────────
|
||||
//
|
||||
// What is left of config/notificationStreams.js once the shard-derived catalog
|
||||
// moved to config/shardStreams.js (MODULE_SYSTEM.md §1.8: push INFRASTRUCTURE is
|
||||
// core, the CATALOG is content). Exactly one stream is core's: `news.post` is
|
||||
// produced by the website's own posts path, not by any game feed.
|
||||
//
|
||||
// Registered through modules/registries.js like any module's, and read back
|
||||
// through it — nothing imports this file to get "the catalog", because the
|
||||
// catalog is core's plus every module's.
|
||||
//
|
||||
// The payload that ever leaves the server is a CONTENT-FREE tickle
|
||||
// ({ stream, ref }); the app wakes and PULLS the real, ownership-checked content
|
||||
// over the authenticated API (docs/android/PLAN.md §11).
|
||||
|
||||
const STREAMS = [
|
||||
{
|
||||
id: 'news.post',
|
||||
label: 'News posts',
|
||||
description: 'New news / Five-on-Friday / newsletter posts.',
|
||||
personal: false,
|
||||
requiresLinkedAccount: false,
|
||||
},
|
||||
]
|
||||
|
||||
module.exports = { STREAMS }
|
||||
@@ -1,7 +1,14 @@
|
||||
// ── Push-notification stream catalog + event → stream mapping ───────────────
|
||||
// ── Shard-derived push streams + event → stream mapping ────────────────────
|
||||
//
|
||||
// The single source of truth for which streams a user can subscribe to, and how
|
||||
// a shard event maps onto them. Two families:
|
||||
// MODULE-UO CONTENT, still living in core. MODULE_SYSTEM.md §1.8 named
|
||||
// config/notificationStreams.js as one of the three genuinely entangled files:
|
||||
// most of its catalog and all of `mapShardEvent` are shard-derived, and it reads
|
||||
// `PUBLIC_KINDS` out of utils/shardBroadcast. PR 4 split it — core's one stream
|
||||
// is config/coreStreams.js, and everything shard-shaped is here, in a file that
|
||||
// moves to module-uo whole in Phase 3. Nothing in core imports it except
|
||||
// modules/registries.js's registerCore(), which is the one line Phase 3 deletes.
|
||||
//
|
||||
// Two families:
|
||||
// • public / opt-in — no linked game account required; delivered to every
|
||||
// subscriber. Drawn ONLY from the SSE public allowlist
|
||||
// (utils/shardBroadcast PUBLIC_KINDS) — a sensitive kind
|
||||
@@ -17,17 +24,7 @@
|
||||
|
||||
const { PUBLIC_KINDS } = require('../utils/shardBroadcast')
|
||||
|
||||
// The subscribable catalog. `news.post` is produced by the website's own posts
|
||||
// path (not the shard feed) — see utils/pushDispatch — so it has no mapShardEvent
|
||||
// case; every other stream is shard-derived below.
|
||||
const STREAMS = [
|
||||
{
|
||||
id: 'news.post',
|
||||
label: 'News posts',
|
||||
description: 'New news / Five-on-Friday / newsletter posts.',
|
||||
personal: false,
|
||||
requiresLinkedAccount: false,
|
||||
},
|
||||
{
|
||||
id: 'server.status',
|
||||
label: 'Server up / down',
|
||||
@@ -79,8 +76,10 @@ const STREAMS = [
|
||||
},
|
||||
]
|
||||
|
||||
const STREAM_IDS = new Set(STREAMS.map((s) => s.id))
|
||||
const isValidStream = (id) => STREAM_IDS.has(id)
|
||||
// The owner-keyed subset, needed by mapShardEvent's public-safety filter below.
|
||||
// Derived from this file's own catalog rather than read back out of the registry:
|
||||
// the filter is about THESE streams, and a module must not be able to weaken it
|
||||
// by registering something that happens to share an id.
|
||||
const PERSONAL_STREAMS = new Set(STREAMS.filter((s) => s.personal).map((s) => s.id))
|
||||
|
||||
// Per-process transition state so full-state upserts (champ.update / city.update
|
||||
@@ -162,7 +161,12 @@ function mapShardEvent(event, tracker = defaultTracker) {
|
||||
// they are exempt from the public allowlist (that is the whole point of the
|
||||
// owner-keyed split). This guarantees a sensitive kind can never leak publicly
|
||||
// even if a future mapping case is added carelessly.
|
||||
//
|
||||
// This filter, the kinds it reads and the streams it protects now all live in
|
||||
// one file and move together — the reason PR 4 dropped the contract's
|
||||
// `mapEvent` half rather than leaving the mapping in core and the catalog in a
|
||||
// module (MODULE_API.md §2.4).
|
||||
return out.filter((t) => (PERSONAL_STREAMS.has(t.streamId) ? true : PUBLIC_KINDS.has(kind)))
|
||||
}
|
||||
|
||||
module.exports = { STREAMS, isValidStream, mapShardEvent, createTracker, PERSONAL_STREAMS }
|
||||
module.exports = { STREAMS, mapShardEvent, createTracker, PERSONAL_STREAMS }
|
||||
215
server/src/config/themePresets.js
Normal file
215
server/src/config/themePresets.js
Normal file
@@ -0,0 +1,215 @@
|
||||
// ── Theme presets & the closed sets an admin may choose from ───────────────
|
||||
//
|
||||
// The single authority for admin-configurable theming (docs/website/THEMING_AND_NAV.md
|
||||
// §5-§6). Everything an admin can pick is enumerated here; nothing is free text.
|
||||
//
|
||||
// Why the server owns this rather than theme.css:
|
||||
// The effective token set is resolved server-side and returned by
|
||||
// settings.getPublic() as `theme`, which the SPA writes onto the document as
|
||||
// CSS custom properties. That keeps ONE authority for the override merge
|
||||
// (:root ← preset ← custom), lets brand.accent — a cross-repo contract the
|
||||
// Android app themes itself from — report the same accent the website paints,
|
||||
// and avoids the precedence trap of `[data-theme]` blocks losing to the inline
|
||||
// `--accent` SiteContext already sets on <html>.
|
||||
//
|
||||
// theme.css's `:root` remains the default and is NOT duplicated here beyond
|
||||
// the runic-gateway preset. An instance with no `theme_visual` row gets no
|
||||
// `theme` block at all and renders from :root exactly as it does today.
|
||||
//
|
||||
// Security note: these values end up as CSS custom property values. Every one is
|
||||
// picked from a closed set (a preset id, a shortlist stack, a bounded px length,
|
||||
// a hex color) — see utils/themeResolve.js, which both the write path and the
|
||||
// read path validate through.
|
||||
|
||||
// The three color tokens that are semantic rather than decorative. They mean
|
||||
// "live" and "maintenance" and stay fixed across every preset — green is not a
|
||||
// brand choice. Deliberately absent from every preset block below.
|
||||
const FIXED_TOKENS = ['--mode-live', '--mode-maint']
|
||||
|
||||
// Full palettes. A preset must carry EVERY color token, not just the eight the
|
||||
// admin form exposes: a partial palette leaves e.g. --line and --blue at their
|
||||
// dark-blue :root values, which reads as broken on a warm background.
|
||||
//
|
||||
// --panel-grad is deliberately absent: it is derived (`linear-gradient(180deg,
|
||||
// var(--panel-a), var(--panel-b))`) and must stay derived, or a future light
|
||||
// preset silently inherits a dark gradient.
|
||||
const PRESETS = {
|
||||
// Today's :root, verbatim. Declared as a preset so that switching back to it
|
||||
// after trying another is the same code path as any other choice.
|
||||
'runic-gateway': {
|
||||
label: 'Runic Gateway',
|
||||
tokens: {
|
||||
'--bg': '#0e1318',
|
||||
'--bg-deep': '#0b0f14',
|
||||
'--panel-a': '#192231',
|
||||
'--panel-b': '#141a21',
|
||||
'--panel-flat': '#11161d',
|
||||
'--line': '#2a3544',
|
||||
'--line-soft': '#1d2733',
|
||||
'--accent': '#7f99bd',
|
||||
'--accent-bright': '#cdd9e8',
|
||||
'--ink': '#eef3f8',
|
||||
'--head': '#e6edf6',
|
||||
'--text': '#c4cdd8',
|
||||
'--muted': '#aeb8c4',
|
||||
'--dim': '#6f7d8e',
|
||||
'--blue': '#13243c',
|
||||
'--radius-pill': '999px',
|
||||
'--radius-panel': '12px',
|
||||
'--radius-card': '10px',
|
||||
'--radius-input': '8px',
|
||||
'--shadow-card': '0 14px 34px rgba(0, 0, 0, 0.3)',
|
||||
'--serif': 'Georgia, "Times New Roman", serif',
|
||||
'--display': 'Cinzel, Georgia, serif',
|
||||
'--sans': '"Helvetica Neue", Arial, sans-serif',
|
||||
},
|
||||
},
|
||||
// Flatter, cooler, sans-heavy. Reads as a SaaS dashboard, not fantasy.
|
||||
modern: {
|
||||
label: 'Modern',
|
||||
tokens: {
|
||||
'--bg': '#101114',
|
||||
'--bg-deep': '#0a0a0c',
|
||||
'--panel-a': '#1c1d22',
|
||||
'--panel-b': '#17181c',
|
||||
'--panel-flat': '#141519',
|
||||
'--line': '#2b2d34',
|
||||
'--line-soft': '#212329',
|
||||
'--accent': '#4f8ef7',
|
||||
'--accent-bright': '#a8c8ff',
|
||||
'--ink': '#f2f3f5',
|
||||
'--head': '#f7f8fa',
|
||||
'--text': '#b8bcc4',
|
||||
'--muted': '#a9aeb8',
|
||||
'--dim': '#71767f',
|
||||
'--blue': '#1b2c47',
|
||||
'--radius-pill': '8px',
|
||||
'--radius-panel': '8px',
|
||||
'--radius-card': '6px',
|
||||
'--radius-input': '6px',
|
||||
'--shadow-card': '0 8px 20px rgba(0, 0, 0, 0.25)',
|
||||
'--serif': 'Inter, Arial, sans-serif',
|
||||
'--display': "'Work Sans', Arial, sans-serif",
|
||||
'--sans': 'Inter, Arial, sans-serif',
|
||||
},
|
||||
},
|
||||
// Warmer, higher contrast, carved corners; leans into UO harder.
|
||||
fantasy: {
|
||||
label: 'Fantasy',
|
||||
tokens: {
|
||||
'--bg': '#1a120b',
|
||||
'--bg-deep': '#120c07',
|
||||
'--panel-a': '#2c1f14',
|
||||
'--panel-b': '#241a10',
|
||||
'--panel-flat': '#1f160d',
|
||||
'--line': '#4a3721',
|
||||
'--line-soft': '#33251a',
|
||||
'--accent': '#c9973f',
|
||||
'--accent-bright': '#e8c374',
|
||||
'--ink': '#f3e8d4',
|
||||
'--head': '#f7efe0',
|
||||
'--text': '#d3bfa0',
|
||||
'--muted': '#bfa985',
|
||||
'--dim': '#8a7454',
|
||||
'--blue': '#382613',
|
||||
'--radius-pill': '4px',
|
||||
'--radius-panel': '3px',
|
||||
'--radius-card': '2px',
|
||||
'--radius-input': '2px',
|
||||
'--shadow-card': '0 16px 38px rgba(0, 0, 0, 0.45)',
|
||||
'--serif': "'EB Garamond', Georgia, serif",
|
||||
'--display': 'Cinzel, Georgia, serif',
|
||||
'--sans': "'EB Garamond', Georgia, serif",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// 'custom' is a valid stored preset meaning "no preset base" — :root plus
|
||||
// whatever custom fields are set. It has no palette of its own.
|
||||
const CUSTOM_PRESET = 'custom'
|
||||
const PRESET_IDS = [...Object.keys(PRESETS), CUSTOM_PRESET]
|
||||
|
||||
// The colors the admin form exposes, mapped to their CSS token. Deliberately
|
||||
// the eight of §6.1 rather than all fifteen: the rest are supporting shades a
|
||||
// preset sets coherently but that are not worth (or safe to) hand-picking.
|
||||
const COLOR_FIELDS = {
|
||||
bg: '--bg',
|
||||
bgDeep: '--bg-deep',
|
||||
panelA: '--panel-a',
|
||||
panelB: '--panel-b',
|
||||
accent: '--accent',
|
||||
accentBright: '--accent-bright',
|
||||
ink: '--ink',
|
||||
text: '--text',
|
||||
}
|
||||
|
||||
const RADIUS_FIELDS = {
|
||||
radiusPill: '--radius-pill',
|
||||
radiusPanel: '--radius-panel',
|
||||
radiusCard: '--radius-card',
|
||||
radiusInput: '--radius-input',
|
||||
}
|
||||
|
||||
const FONT_FIELDS = {
|
||||
serif: '--serif',
|
||||
display: '--display',
|
||||
sans: '--sans',
|
||||
}
|
||||
|
||||
// The curated Google Fonts shortlist (§5.1). The dropdown's VALUE is the full
|
||||
// stack exactly as applied, so no string is ever built from admin input and no
|
||||
// Google Fonts URL is ever assembled at runtime — the combined css2? request in
|
||||
// client/index.html is static and covers all eight web families.
|
||||
//
|
||||
// One addition to §5.1's twelve: Georgia in the serif list. The shortlist as
|
||||
// drafted gave the sans role a "current default" option (Arial, byte-identical
|
||||
// to today's --sans) but left the serif role with no way back to today's
|
||||
// `Georgia, "Times New Roman", serif` short of resetting the whole theme. It
|
||||
// pulls in no web family, so §5.2's URL is unchanged.
|
||||
const FONT_OPTIONS = {
|
||||
serif: [
|
||||
{ value: "'EB Garamond', Georgia, serif", label: 'EB Garamond — strongest fantasy/historic' },
|
||||
{ value: 'Merriweather, Georgia, serif', label: 'Merriweather — excellent readability' },
|
||||
{ value: "'Playfair Display', Georgia, serif", label: 'Playfair Display — elegant/editorial' },
|
||||
{ value: "'IM Fell English', Georgia, serif", label: 'IM Fell English — old-world (no bold weight)' },
|
||||
{ value: 'Georgia, "Times New Roman", serif', label: 'Georgia — the shipped default' },
|
||||
],
|
||||
display: [
|
||||
{ value: 'Cinzel, Georgia, serif', label: 'Cinzel — current Runic Gateway identity' },
|
||||
{ value: "'Playfair Display', Georgia, serif", label: 'Playfair Display — elegant alternative' },
|
||||
{ value: "'EB Garamond', Georgia, serif", label: 'EB Garamond — softer/classic' },
|
||||
{ value: "'IM Fell English', Georgia, serif", label: 'IM Fell English — very strong fantasy (no bold weight)' },
|
||||
],
|
||||
sans: [
|
||||
{ value: 'Inter, Arial, sans-serif', label: 'Inter — default modern UI choice' },
|
||||
{ value: "'Work Sans', Arial, sans-serif", label: 'Work Sans — slightly more character' },
|
||||
{ value: "'Source Sans 3', Arial, sans-serif", label: 'Source Sans 3 — extremely readable' },
|
||||
{ value: '"Helvetica Neue", Arial, sans-serif', label: 'Arial — no webfont; the shipped default' },
|
||||
],
|
||||
}
|
||||
|
||||
// Shadow depth, as a closed set for the same reason fonts are: the stored value
|
||||
// is applied verbatim as --shadow-card.
|
||||
const SHADOW_OPTIONS = [
|
||||
{ value: 'none', label: 'None — flat' },
|
||||
{ value: '0 8px 20px rgba(0, 0, 0, 0.25)', label: 'Soft' },
|
||||
{ value: '0 14px 34px rgba(0, 0, 0, 0.3)', label: 'Default' },
|
||||
{ value: '0 18px 44px rgba(0, 0, 0, 0.45)', label: 'Deep' },
|
||||
]
|
||||
|
||||
// Corner radius is a number, not a shortlist, so it is bounded instead: an
|
||||
// integer count of px from 0 to 999 (999 being the pill).
|
||||
const RADIUS_MAX_PX = 999
|
||||
|
||||
module.exports = {
|
||||
PRESETS,
|
||||
PRESET_IDS,
|
||||
CUSTOM_PRESET,
|
||||
FIXED_TOKENS,
|
||||
COLOR_FIELDS,
|
||||
RADIUS_FIELDS,
|
||||
FONT_FIELDS,
|
||||
FONT_OPTIONS,
|
||||
SHADOW_OPTIONS,
|
||||
RADIUS_MAX_PX,
|
||||
}
|
||||
@@ -118,6 +118,19 @@ const passwordResetConfirmLimiter = makeLimiter({
|
||||
message: 'Too many attempts. Please try again later.',
|
||||
})
|
||||
|
||||
// The player-vendor market search. The first genuinely expensive PUBLIC endpoint
|
||||
// on the site: every call is a LIKE scan plus a COUNT over the listings table,
|
||||
// which on a large shard is the biggest table there is, and it is anonymous by
|
||||
// default. Generous for a human browsing shops (a typed search is debounced to
|
||||
// one request, and paging is a click), tight enough that it cannot be used as a
|
||||
// cheap way to load the database.
|
||||
const marketLimiter = makeLimiter({
|
||||
windowMs: 60 * 1000,
|
||||
max: 60,
|
||||
label: 'market',
|
||||
message: 'Too many searches. Please slow down.',
|
||||
})
|
||||
|
||||
// CSP violation reports. Unauthenticated by necessity (browsers send them with no
|
||||
// session), and every accepted report writes a log line — so an attacker who can get
|
||||
// a victim to load a page could otherwise use it as a log-flood amplifier. Generous
|
||||
@@ -141,5 +154,6 @@ module.exports = {
|
||||
mobileSsoExchangeLimiter,
|
||||
passwordResetRequestLimiter,
|
||||
passwordResetConfirmLimiter,
|
||||
marketLimiter,
|
||||
cspReportLimiter,
|
||||
}
|
||||
|
||||
@@ -1,26 +1,68 @@
|
||||
// ── Announcement pipeline: SQL ─────────────────────────────────────────────
|
||||
//
|
||||
// Two tables since PR 4 (MODULE_SYSTEM.md §1.8): `announce_jobs` is one row per
|
||||
// publish event, `announce_job_legs` one row per delivery leg of that job. The
|
||||
// leg set is registered rather than fixed, so a leg is a stored VALUE now instead
|
||||
// of a group of leg-prefixed columns — which is what lets a module bring its own
|
||||
// leg without altering a core table.
|
||||
//
|
||||
// Every read returns the job with a `legs` array attached, so a caller never has
|
||||
// to remember to fetch the second table.
|
||||
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
const COLS =
|
||||
'id, post_id, status, ' +
|
||||
'towncrier_status, towncrier_attempts, towncrier_last_error, towncrier_next_attempt_at, ' +
|
||||
'discord_status, discord_attempts, discord_last_error, discord_next_attempt_at, ' +
|
||||
'created_at, updated_at'
|
||||
const COLS = 'id, post_id, status, created_at, updated_at'
|
||||
const LEG_COLS = 'job_id, leg, status, attempts, last_error, next_attempt_at'
|
||||
|
||||
// Whitelist so a `leg` value can be interpolated into a column name safely — it
|
||||
// never comes from raw user input, but keep the guard explicit.
|
||||
const LEGS = ['towncrier', 'discord']
|
||||
function assertLeg(leg) {
|
||||
if (!LEGS.includes(leg)) throw new Error(`unknown announce leg: ${leg}`)
|
||||
async function legsFor(jobIds) {
|
||||
if (jobIds.length === 0) return new Map()
|
||||
const marks = jobIds.map(() => '?').join(', ')
|
||||
const rows = await query(
|
||||
`SELECT ${LEG_COLS} FROM announce_job_legs WHERE job_id IN (${marks}) ORDER BY job_id, leg`,
|
||||
jobIds,
|
||||
)
|
||||
const byJob = new Map(jobIds.map((id) => [id, []]))
|
||||
for (const row of rows) byJob.get(row.job_id).push(row)
|
||||
return byJob
|
||||
}
|
||||
|
||||
async function create(postId) {
|
||||
async function attachLegs(jobs) {
|
||||
const byJob = await legsFor(jobs.map((j) => j.id))
|
||||
for (const job of jobs) job.legs = byJob.get(job.id) || []
|
||||
return jobs
|
||||
}
|
||||
|
||||
// Create a job and its leg rows in one go. `legs` is the registered leg id list —
|
||||
// an empty list is legal and yields a job with nothing to deliver.
|
||||
async function create(postId, legs = []) {
|
||||
const res = await query('INSERT INTO announce_jobs (post_id) VALUES (?)', [postId])
|
||||
return res.insertId
|
||||
const jobId = Number(res.insertId)
|
||||
if (legs.length > 0) {
|
||||
const values = legs.map(() => '(?, ?)').join(', ')
|
||||
await query(
|
||||
`INSERT INTO announce_job_legs (job_id, leg) VALUES ${values}`,
|
||||
legs.flatMap((leg) => [jobId, leg]),
|
||||
)
|
||||
}
|
||||
return jobId
|
||||
}
|
||||
|
||||
// Add any registered legs this job is missing. A job enqueued before a module was
|
||||
// installed has no row for that module's leg, and without this it could never
|
||||
// deliver one — the worker only ever sees rows that exist.
|
||||
async function ensureLegs(jobId, legs = []) {
|
||||
if (legs.length === 0) return
|
||||
const values = legs.map(() => '(?, ?)').join(', ')
|
||||
await query(
|
||||
`INSERT IGNORE INTO announce_job_legs (job_id, leg) VALUES ${values}`,
|
||||
legs.flatMap((leg) => [jobId, leg]),
|
||||
)
|
||||
}
|
||||
|
||||
async function findById(id) {
|
||||
const rows = await query(`SELECT ${COLS} FROM announce_jobs WHERE id = ? LIMIT 1`, [id])
|
||||
return rows[0] || null
|
||||
if (rows.length === 0) return null
|
||||
return (await attachLegs(rows))[0]
|
||||
}
|
||||
|
||||
async function findByPostId(postId) {
|
||||
@@ -28,36 +70,38 @@ async function findByPostId(postId) {
|
||||
`SELECT ${COLS} FROM announce_jobs WHERE post_id = ? ORDER BY id DESC LIMIT 1`,
|
||||
[postId],
|
||||
)
|
||||
return rows[0] || null
|
||||
if (rows.length === 0) return null
|
||||
return (await attachLegs(rows))[0]
|
||||
}
|
||||
|
||||
// Jobs with at least one leg that is due now: pending and either never scheduled
|
||||
// (next_attempt_at IS NULL — a fresh enqueue) or past its backoff time.
|
||||
// (next_attempt_at IS NULL — a fresh enqueue) or past its backoff time. Returns
|
||||
// whole jobs with every leg attached; the worker decides which legs to run, so
|
||||
// this stays one query regardless of how many legs are registered.
|
||||
async function findDue(now = new Date(), limit = 25) {
|
||||
return query(
|
||||
`SELECT ${COLS} FROM announce_jobs
|
||||
WHERE (towncrier_status = 'pending'
|
||||
AND (towncrier_next_attempt_at IS NULL OR towncrier_next_attempt_at <= ?))
|
||||
OR (discord_status = 'pending'
|
||||
AND (discord_next_attempt_at IS NULL OR discord_next_attempt_at <= ?))
|
||||
ORDER BY id ASC
|
||||
const rows = await query(
|
||||
`SELECT ${COLS} FROM announce_jobs j
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM announce_job_legs l
|
||||
WHERE l.job_id = j.id
|
||||
AND l.status = 'pending'
|
||||
AND (l.next_attempt_at IS NULL OR l.next_attempt_at <= ?))
|
||||
ORDER BY j.id ASC
|
||||
LIMIT ?`,
|
||||
[now, now, limit],
|
||||
[now, limit],
|
||||
)
|
||||
return attachLegs(rows)
|
||||
}
|
||||
|
||||
// Update one leg's columns. `fields` uses leg-agnostic keys (status, attempts,
|
||||
// lastError, nextAttemptAt); we map them onto the leg-prefixed columns.
|
||||
async function updateLeg(id, leg, { status, attempts, lastError, nextAttemptAt }) {
|
||||
assertLeg(leg)
|
||||
// Update one leg's row. `leg` is a bound VALUE, not an interpolated column name —
|
||||
// the reason the old leg allowlist that guarded that interpolation is gone. A
|
||||
// module's leg id could not have passed it anyway.
|
||||
async function updateLeg(jobId, leg, { status, attempts, lastError, nextAttemptAt }) {
|
||||
await query(
|
||||
`UPDATE announce_jobs SET
|
||||
${leg}_status = ?,
|
||||
${leg}_attempts = ?,
|
||||
${leg}_last_error = ?,
|
||||
${leg}_next_attempt_at = ?
|
||||
WHERE id = ?`,
|
||||
[status, attempts, lastError ?? null, nextAttemptAt ?? null, id],
|
||||
`UPDATE announce_job_legs SET
|
||||
status = ?, attempts = ?, last_error = ?, next_attempt_at = ?
|
||||
WHERE job_id = ? AND leg = ?`,
|
||||
[status, attempts, lastError ?? null, nextAttemptAt ?? null, jobId, leg],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -65,4 +109,4 @@ async function setStatus(id, status) {
|
||||
await query('UPDATE announce_jobs SET status = ? WHERE id = ?', [status, id])
|
||||
}
|
||||
|
||||
module.exports = { LEGS, create, findById, findByPostId, findDue, updateLeg, setStatus }
|
||||
module.exports = { create, ensureLegs, findById, findByPostId, findDue, updateLeg, setStatus }
|
||||
|
||||
@@ -1,87 +1,38 @@
|
||||
// ── Announcement pipeline: pure logic ──────────────────────────────────────
|
||||
//
|
||||
// No DB, no network — just the decisions the worker and model make, kept here so
|
||||
// they are unit-testable in isolation (server/test/announceJobs.test.js):
|
||||
// • buildTownCrierText — turn a post into sidecar-safe town-crier lines
|
||||
// • classifyTownCrier / classifyDiscord — map a dispatch result to done / retry
|
||||
// / terminal, so a data problem fails fast and a transient outage retries
|
||||
// No DB, no network — just the LEG-AGNOSTIC decisions the worker and model make,
|
||||
// kept here so they are unit-testable in isolation (server/test/announceJobs.test.js):
|
||||
// • scheduleAfter — exponential backoff schedule + the attempt cap
|
||||
// • rollupStatus — derive the parent job status from the two legs
|
||||
|
||||
const { deriveExcerpt } = require('../../utils/sanitizeHtml')
|
||||
|
||||
// Sidecar town-crier caps, mirrored from the admin route validation
|
||||
// (admin/uoLink.router.js: lines isArray({ max: 8 }), lines.* isLength({ max: 200 })).
|
||||
// We pre-truncate to these so a published post never bounces with towncrier.error.
|
||||
const MAX_LINES = 8
|
||||
const MAX_LINE_LEN = 200
|
||||
// • rollupStatus — derive the parent job status from its legs
|
||||
// • legError — squeeze a client result into one error line
|
||||
// • baseUrl / articleUrl — the public link an announcement carries
|
||||
//
|
||||
// What used to be here and is not any more: `buildTownCrierText`,
|
||||
// `classifyTownCrier` and `classifyDiscord`. A leg's own text-building and result
|
||||
// classification belong to the leg, and a leg is a registration now
|
||||
// (MODULE_SYSTEM.md §1.8) — they live in utils/shardAnnounce.js and
|
||||
// utils/discordAnnounce.js. This file is what every leg shares.
|
||||
|
||||
// Backoff between retries, indexed by attempts-so-far. Six attempts spread over
|
||||
// ~a couple of hours; after the last one a leg is marked failed and surfaced in
|
||||
// the post's admin panel. Shared by both legs.
|
||||
// the post's admin panel. Shared by every leg.
|
||||
const BACKOFF_MS = [30_000, 120_000, 600_000, 1_800_000, 3_600_000, 7_200_000]
|
||||
const MAX_ATTEMPTS = BACKOFF_MS.length
|
||||
|
||||
// Trim to a hard length, appending an ellipsis only when something was cut.
|
||||
function clamp(value, max) {
|
||||
const s = String(value == null ? '' : value)
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
if (s.length <= max) return s
|
||||
return `${s.slice(0, max - 1).trimEnd()}…`
|
||||
// The site's public base, used to build the link an announcement carries.
|
||||
function baseUrl() {
|
||||
return (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
// The public link that goes in the announcement. News has no per-post route
|
||||
// (App.jsx only has the /site/news list), so we link the list — matches the
|
||||
// pre-pipeline Discord announce behavior.
|
||||
function articleUrl(baseUrl) {
|
||||
return `${String(baseUrl || '').replace(/\/+$/, '')}/site/news`
|
||||
}
|
||||
|
||||
// Build the town-crier lines: title, a one-line excerpt, then the URL. Each line
|
||||
// is clamped to the sidecar's per-line cap and the whole thing to the line-count
|
||||
// cap. Falls back to a stripped body excerpt when the post has no excerpt.
|
||||
function buildTownCrierText(post, { baseUrl } = {}) {
|
||||
const title = clamp(post.title, MAX_LINE_LEN)
|
||||
const excerptSource = post.excerpt || deriveExcerpt(post.body, MAX_LINE_LEN) || ''
|
||||
const lines = [title]
|
||||
const excerpt = clamp(excerptSource, MAX_LINE_LEN)
|
||||
if (excerpt) lines.push(excerpt)
|
||||
const url = clamp(articleUrl(baseUrl), MAX_LINE_LEN)
|
||||
if (url) lines.push(url)
|
||||
return lines.filter(Boolean).slice(0, MAX_LINES)
|
||||
}
|
||||
|
||||
// ── Result classification ──────────────────────────────────────────────────
|
||||
// Both clients return { ok, status, error }. Map that to one of:
|
||||
// done — delivered, mark the leg done
|
||||
// retry — transient (shard restarting, bot down, network); back off + retry
|
||||
// terminal — will never succeed as-is (over caps, bad auth/config); fail now
|
||||
|
||||
function classifyTownCrier(result) {
|
||||
if (result && result.ok) return { outcome: 'done' }
|
||||
const status = result ? result.status : 0
|
||||
// 400 = over the line/duration caps (a data problem — do NOT retry).
|
||||
// 401 = token mismatch, 409 = protocol mismatch (both config problems).
|
||||
if (status === 400 || status === 401 || status === 409) {
|
||||
return { outcome: 'terminal', error: legError(result) }
|
||||
}
|
||||
// 503 (shard not connected), 504 (shard timeout), 0 (network/timeout / not
|
||||
// configured yet), and any other 5xx are transient — retry.
|
||||
return { outcome: 'retry', error: legError(result) }
|
||||
}
|
||||
|
||||
function classifyDiscord(result) {
|
||||
if (result && result.ok) return { outcome: 'done' }
|
||||
// The bot's /internal/announce collapses failures (503 = not connected,
|
||||
// 400 = no news channel configured) without surfacing Discord's own
|
||||
// retry_after, so there is no reliable terminal signal to key on here. Retry
|
||||
// every failure on the shared backoff; a genuine config problem simply
|
||||
// exhausts its attempts and lands as `failed` in the admin panel, where the
|
||||
// per-leg retry button re-runs it after the channel is set.
|
||||
return { outcome: 'retry', error: legError(result) }
|
||||
function articleUrl(base) {
|
||||
return `${String(base || '').replace(/\/+$/, '')}/site/news`
|
||||
}
|
||||
|
||||
// Every leg's client returns { ok, status, data, error }. Squeeze a failure into
|
||||
// the one line stored in announce_job_legs.last_error and shown in the panel.
|
||||
function legError(result) {
|
||||
if (!result) return 'no response'
|
||||
if (result.status) {
|
||||
@@ -99,29 +50,32 @@ function scheduleAfter(attempts) {
|
||||
return BACKOFF_MS[Math.min(attempts - 1, BACKOFF_MS.length - 1)]
|
||||
}
|
||||
|
||||
// Parent job status derived from the two leg statuses:
|
||||
// done — both legs delivered
|
||||
// failed — both legs gave up
|
||||
// partial — at least one leg reached a terminal state while the other has not
|
||||
// matched it (still pending/retrying, or the opposite terminal state)
|
||||
// pending — neither leg is terminal yet
|
||||
function rollupStatus(towncrierStatus, discordStatus) {
|
||||
if (towncrierStatus === 'done' && discordStatus === 'done') return 'done'
|
||||
if (towncrierStatus === 'failed' && discordStatus === 'failed') return 'failed'
|
||||
// Parent job status derived from its leg statuses:
|
||||
// done — every leg delivered
|
||||
// failed — every leg gave up
|
||||
// partial — at least one leg reached a terminal state without all of them
|
||||
// agreeing (some still pending/retrying, or a mix of done and failed)
|
||||
// pending — no leg is terminal yet
|
||||
//
|
||||
// Takes the list of leg statuses rather than two named arguments, because the leg
|
||||
// set is registered rather than fixed (MODULE_SYSTEM.md §1.8). No legs at all
|
||||
// rolls up `done`: with nothing registered there is nothing left to deliver, and
|
||||
// leaving such jobs `pending` would pile up rows the worker never touches.
|
||||
function rollupStatus(statuses) {
|
||||
const list = Array.isArray(statuses) ? statuses : []
|
||||
const terminal = (s) => s === 'done' || s === 'failed'
|
||||
if (terminal(towncrierStatus) || terminal(discordStatus)) return 'partial'
|
||||
if (list.every((s) => s === 'done')) return 'done'
|
||||
if (list.every((s) => s === 'failed')) return 'failed'
|
||||
if (list.some(terminal)) return 'partial'
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAX_LINES,
|
||||
MAX_LINE_LEN,
|
||||
MAX_ATTEMPTS,
|
||||
BACKOFF_MS,
|
||||
buildTownCrierText,
|
||||
baseUrl,
|
||||
articleUrl,
|
||||
classifyTownCrier,
|
||||
classifyDiscord,
|
||||
legError,
|
||||
scheduleAfter,
|
||||
rollupStatus,
|
||||
}
|
||||
|
||||
@@ -2,19 +2,21 @@
|
||||
//
|
||||
// Sits between the DB rows and the worker: creates jobs on publish, records each
|
||||
// leg's outcome, keeps the parent `status` rollup in sync, stamps the post's
|
||||
// announced_at when both legs land, and resets a leg for the admin retry button.
|
||||
// The pure decisions (backoff, rollup, classification) live in .logic.js.
|
||||
// announced_at when every leg lands, and resets a leg for the admin retry button.
|
||||
// The pure decisions (backoff, rollup) live in .logic.js; which legs exist at all
|
||||
// is modules/registries.js's answer, not this file's (MODULE_SYSTEM.md §1.8).
|
||||
|
||||
const db = require('./announceJobs.db')
|
||||
const logic = require('./announceJobs.logic')
|
||||
const registries = require('../../modules/registries')
|
||||
const posts = require('../posts/posts.model')
|
||||
const log = require('../../utils/logger')('announce')
|
||||
|
||||
// Enqueue an announcement for a freshly-published news post: one job row (both
|
||||
// legs pending, due immediately) plus a back-pointer on the post so the admin
|
||||
// panel can find it. Returns the new job id.
|
||||
// Enqueue an announcement for a freshly-published news post: one job row, one leg
|
||||
// row per registered leg (all pending, due immediately), plus a back-pointer on
|
||||
// the post so the admin panel can find it. Returns the new job id.
|
||||
async function enqueue(postId) {
|
||||
const jobId = await db.create(postId)
|
||||
const jobId = await db.create(postId, registries.announceLegIds())
|
||||
await posts.linkAnnounceJob(postId, jobId)
|
||||
log.info('announce job enqueued', { jobId, postId })
|
||||
return jobId
|
||||
@@ -43,12 +45,13 @@ async function enqueueIfNeeded(post, transition) {
|
||||
}
|
||||
}
|
||||
|
||||
// Record a leg's dispatch outcome and refresh the rollup. `outcome` is one of
|
||||
// logic.classify*'s results: 'done' | 'retry' | 'terminal'. For 'retry' we bump
|
||||
// the attempt count and schedule the next run (or fail the leg once the cap is
|
||||
// hit). Returns the updated job row.
|
||||
// Record a leg's dispatch outcome and refresh the rollup. `outcome` is one of a
|
||||
// leg's classify() results: 'done' | 'retry' | 'terminal'. For 'retry' we bump the
|
||||
// attempt count and schedule the next run (or fail the leg once the cap is hit).
|
||||
// Returns the updated job row.
|
||||
async function recordOutcome(job, leg, { outcome, error }) {
|
||||
const attempts = Number(job[`${leg}_attempts`]) || 0
|
||||
const row = (job.legs || []).find((l) => l.leg === leg)
|
||||
const attempts = Number(row && row.attempts) || 0
|
||||
|
||||
if (outcome === 'done') {
|
||||
await db.updateLeg(job.id, leg, { status: 'done', attempts, lastError: null, nextAttemptAt: null })
|
||||
@@ -71,12 +74,12 @@ async function recordOutcome(job, leg, { outcome, error }) {
|
||||
return refreshStatus(job.id)
|
||||
}
|
||||
|
||||
// Recompute and persist the parent status from the two legs; stamp the post's
|
||||
// announced_at the moment both legs have delivered.
|
||||
// Recompute and persist the parent status from the legs; stamp the post's
|
||||
// announced_at the moment every leg has delivered.
|
||||
async function refreshStatus(jobId) {
|
||||
const job = await db.findById(jobId)
|
||||
if (!job) return null
|
||||
const status = logic.rollupStatus(job.towncrier_status, job.discord_status)
|
||||
const status = logic.rollupStatus(job.legs.map((l) => l.status))
|
||||
if (status !== job.status) await db.setStatus(jobId, status)
|
||||
job.status = status
|
||||
if (status === 'done') {
|
||||
@@ -93,16 +96,33 @@ async function refreshStatus(jobId) {
|
||||
// the worker pick it up on the next tick. Resets the attempt count so a retry
|
||||
// after a config fix gets a full budget again.
|
||||
async function resetLeg(postId, leg) {
|
||||
if (!db.LEGS.includes(leg)) throw new Error(`unknown announce leg: ${leg}`)
|
||||
if (!registries.announceLeg(leg)) throw new Error(`unknown announce leg: ${leg}`)
|
||||
const job = await db.findByPostId(postId)
|
||||
if (!job) return null
|
||||
// A job enqueued before this leg was registered has no row for it; create it so
|
||||
// the retry button works on an existing post after a module is installed.
|
||||
await db.ensureLegs(job.id, [leg])
|
||||
await db.updateLeg(job.id, leg, { status: 'pending', attempts: 0, lastError: null, nextAttemptAt: null })
|
||||
log.info('announce leg reset for retry', { jobId: job.id, postId, leg })
|
||||
return refreshStatus(job.id)
|
||||
// Labelled, because this is the response body the admin panel re-renders from.
|
||||
return withLabels(await refreshStatus(job.id))
|
||||
}
|
||||
|
||||
// Decorate a job's legs with the label their registration carries, so the admin
|
||||
// panel renders a module's leg with a real name and no client change
|
||||
// (MODULE_SYSTEM.md §1.8). An unregistered leg — a stale row from a module that
|
||||
// was since removed — keeps its id as the label rather than disappearing.
|
||||
function withLabels(job) {
|
||||
if (!job) return job
|
||||
job.legs = (job.legs || []).map((l) => {
|
||||
const registered = registries.announceLeg(l.leg)
|
||||
return { ...l, label: registered ? registered.label : l.leg }
|
||||
})
|
||||
return job
|
||||
}
|
||||
|
||||
async function getByPostId(postId) {
|
||||
return db.findByPostId(postId)
|
||||
return withLabels(await db.findByPostId(postId))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
@@ -113,4 +133,5 @@ module.exports = {
|
||||
refreshStatus,
|
||||
resetLeg,
|
||||
getByPostId,
|
||||
withLabels,
|
||||
}
|
||||
|
||||
59
server/src/model/modules/modules.db.js
Normal file
59
server/src/model/modules/modules.db.js
Normal file
@@ -0,0 +1,59 @@
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
// SQL for installed_modules — the module system's record of what is installed and
|
||||
// what happened to it on the last boot (db/schema.sql, docs/website/MODULE_SYSTEM.md
|
||||
// §2.4). Rows are keyed by module id. All state rules live in modules.model.js;
|
||||
// this file only moves rows.
|
||||
|
||||
const COLS = `id, name, version, state, failure_stage, failure_reason,
|
||||
source, sha256, installed_at, started_at, updated_at`
|
||||
|
||||
const listAll = () => query(`SELECT ${COLS} FROM installed_modules ORDER BY id`)
|
||||
|
||||
const getOne = (id) => query(`SELECT ${COLS} FROM installed_modules WHERE id = ?`, [id])
|
||||
|
||||
// Write (or refresh) the row for an installed module. A re-install or an upgrade
|
||||
// updates the metadata and deliberately leaves `state` alone: upgrading an enabled
|
||||
// module must not silently disable it, and re-installing a disabled one must not
|
||||
// silently switch it back on. A brand-new row lands in `installed`, the transient
|
||||
// state the next restart resolves.
|
||||
const upsert = ({ id, name, version, source, sha256 }) =>
|
||||
query(
|
||||
`INSERT INTO installed_modules (id, name, version, source, sha256, state)
|
||||
VALUES (?, ?, ?, ?, ?, 'installed')
|
||||
ON DUPLICATE KEY UPDATE
|
||||
name = VALUES(name),
|
||||
version = VALUES(version),
|
||||
source = VALUES(source),
|
||||
sha256 = VALUES(sha256)`,
|
||||
[id, name, version, source ?? null, sha256 ?? null],
|
||||
)
|
||||
|
||||
// Move one row to a new state. `failureStage`/`failureReason` are written on every
|
||||
// call — a non-failing transition passes nulls, which is what clears a stale reason
|
||||
// off a module that has since come up. `stampStarted` sets started_at to now.
|
||||
const setState = ({ id, state, failureStage = null, failureReason = null, stampStarted = false }) =>
|
||||
query(
|
||||
`UPDATE installed_modules
|
||||
SET state = ?, failure_stage = ?, failure_reason = ?
|
||||
${stampStarted ? ', started_at = CURRENT_TIMESTAMP' : ''}
|
||||
WHERE id = ?`,
|
||||
[state, failureStage, failureReason, id],
|
||||
)
|
||||
|
||||
// Boot reset: every row the operator has not disabled goes back to `enabled` with
|
||||
// no failure recorded, so the load that follows writes this boot's outcome rather
|
||||
// than leaving the last one on display. `disabled` is untouched — it is a decision,
|
||||
// not an outcome.
|
||||
const resetForBoot = () =>
|
||||
query(
|
||||
`UPDATE installed_modules
|
||||
SET state = 'enabled', failure_stage = NULL, failure_reason = NULL
|
||||
WHERE state <> 'disabled'`,
|
||||
)
|
||||
|
||||
// Drop the row entirely. Only the explicit purge does this (§2.5); a plain
|
||||
// uninstall disables the module and keeps its row and its data.
|
||||
const remove = (id) => query('DELETE FROM installed_modules WHERE id = ?', [id])
|
||||
|
||||
module.exports = { listAll, getOne, upsert, setState, resetForBoot, remove }
|
||||
183
server/src/model/modules/modules.model.js
Normal file
183
server/src/model/modules/modules.model.js
Normal file
@@ -0,0 +1,183 @@
|
||||
// The module state machine (docs/website/MODULE_SYSTEM.md §2.4, MODULE_API.md §4.4).
|
||||
//
|
||||
// installed ──► enabled ──► started
|
||||
// │ │
|
||||
// │ └──► startup_failed ──┐
|
||||
// │ │ (retry)
|
||||
// └──────────────► disabled ◄─────────┘
|
||||
//
|
||||
// One row per installed module, one column holding the state. The rules that make
|
||||
// the machine mean anything live here, not in the SQL:
|
||||
//
|
||||
// - `installed` is transient. An install writes the row; the restart that follows
|
||||
// resolves it to `started` or `startup_failed` (§2.5).
|
||||
// - `disabled` is the only state a boot leaves alone. It is the operator's
|
||||
// decision; every other state is an outcome and is recomputed each boot by
|
||||
// beginBoot(). That is what makes a fixed module recover on restart without
|
||||
// anyone visiting the admin panel.
|
||||
// - A failure is recorded with the stage it happened at, and every non-failing
|
||||
// transition clears it — a running module can never show a stale reason.
|
||||
//
|
||||
// What this table does NOT decide is which routes exist. The loader scans the
|
||||
// filesystem at require time, before the database is reachable (MODULE_API.md §4.1),
|
||||
// so a disabled module is still mounted and simply guarded (§4.5). Keeping the URL
|
||||
// surface a property of the volume is what lets routes.manifest.json be generated
|
||||
// off a dead database.
|
||||
|
||||
const db = require('./modules.db')
|
||||
|
||||
const STATES = ['installed', 'enabled', 'disabled', 'started', 'startup_failed']
|
||||
|
||||
// The stage a failure happened at: MODULE_API.md §4.3's seven validation steps,
|
||||
// plus `boot` for an onBoot hook that threw (§2.5).
|
||||
const FAILURE_STAGES = [
|
||||
'manifest',
|
||||
'core_api',
|
||||
'mounts',
|
||||
'extensions',
|
||||
'schema',
|
||||
'require',
|
||||
'register',
|
||||
'boot',
|
||||
]
|
||||
|
||||
class ModuleStateError extends Error {
|
||||
constructor(code, message) {
|
||||
super(message)
|
||||
this.name = 'ModuleStateError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
// Legal moves, keyed by target state. Anything not listed is a bug in the caller
|
||||
// and throws rather than writing a row that misrepresents what happened.
|
||||
const ALLOWED_FROM = {
|
||||
// Enabling is the recovery path as well as the first step: a disabled module the
|
||||
// operator switches back on, and a startup_failed one they retry, both land here.
|
||||
enabled: ['installed', 'enabled', 'disabled', 'startup_failed', 'started'],
|
||||
// The operator may disable a module in any state, including one that is running.
|
||||
disabled: STATES,
|
||||
// Reached from `enabled` on a normal boot, and from `installed` on the first boot
|
||||
// after an install (or for a directory placed on the volume by hand, whose row is
|
||||
// written moments earlier in the same boot).
|
||||
started: ['installed', 'enabled'],
|
||||
// Failure always precedes `started` in the lifecycle; `started` is accepted so a
|
||||
// late failure can still be recorded truthfully rather than dropped.
|
||||
startup_failed: ['installed', 'enabled', 'started'],
|
||||
}
|
||||
|
||||
// row → API shape.
|
||||
function serialize(row) {
|
||||
if (!row) return null
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
version: row.version,
|
||||
state: row.state,
|
||||
failureStage: row.failure_stage ?? null,
|
||||
failureReason: row.failure_reason ?? null,
|
||||
source: row.source ?? null,
|
||||
sha256: row.sha256 ?? null,
|
||||
installedAt: row.installed_at ?? null,
|
||||
startedAt: row.started_at ?? null,
|
||||
updatedAt: row.updated_at ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
async function list() {
|
||||
const rows = await db.listAll()
|
||||
return rows.map(serialize)
|
||||
}
|
||||
|
||||
async function get(id) {
|
||||
const rows = await db.getOne(id)
|
||||
return serialize(rows[0])
|
||||
}
|
||||
|
||||
// Record an install (or a re-install / upgrade). Metadata is refreshed; the state is
|
||||
// left as it is, so upgrading an enabled module does not switch it off and
|
||||
// re-installing a disabled one does not switch it on. A new row lands in `installed`.
|
||||
async function recordInstalled({ id, name, version, source = null, sha256 = null }) {
|
||||
if (!id || !name || !version) {
|
||||
throw new ModuleStateError('invalid_module', 'id, name and version are required')
|
||||
}
|
||||
await db.upsert({ id, name, version, source, sha256 })
|
||||
return get(id)
|
||||
}
|
||||
|
||||
// Start of boot: clear the last boot's outcomes so what is on display after this
|
||||
// boot is what this boot did. Leaves `disabled` rows alone (see the header).
|
||||
// Returns the number of rows reset.
|
||||
async function beginBoot() {
|
||||
const res = await db.resetForBoot()
|
||||
return res?.affectedRows ?? 0
|
||||
}
|
||||
|
||||
// Apply one transition, after checking it is legal for the row's current state.
|
||||
// A row that does not exist is not an error the caller can act on — a module can be
|
||||
// present on the volume with no row at all — so it returns null and writes nothing.
|
||||
async function transition(id, target, { failureStage = null, failureReason = null } = {}) {
|
||||
const current = await get(id)
|
||||
if (!current) return null
|
||||
|
||||
const allowed = ALLOWED_FROM[target]
|
||||
if (!allowed.includes(current.state)) {
|
||||
throw new ModuleStateError(
|
||||
'illegal_transition',
|
||||
`module '${id}': cannot move from '${current.state}' to '${target}'`,
|
||||
)
|
||||
}
|
||||
|
||||
await db.setState({
|
||||
id,
|
||||
state: target,
|
||||
failureStage,
|
||||
failureReason,
|
||||
stampStarted: target === 'started',
|
||||
})
|
||||
return get(id)
|
||||
}
|
||||
|
||||
const enable = (id) => transition(id, 'enabled')
|
||||
const disable = (id) => transition(id, 'disabled')
|
||||
const markStarted = (id) => transition(id, 'started')
|
||||
|
||||
// Record a failure at a named stage. Two deliberate softenings, both because this is
|
||||
// called from the boot path where throwing would turn one module's failure into
|
||||
// everybody's (MODULE_API.md §4.4 — the failing module fails alone):
|
||||
//
|
||||
// - a `disabled` row is a no-op. The operator switched it off; a broken module
|
||||
// they already disabled is not news, and overwriting their decision with an
|
||||
// outcome would silently re-enable it on the next boot.
|
||||
// - an unrecognised stage is recorded as `require` rather than rejected, so a
|
||||
// miscategorised failure still reaches the admin panel with its reason intact.
|
||||
async function markStartupFailed(id, { stage, reason }) {
|
||||
const current = await get(id)
|
||||
if (!current || current.state === 'disabled') return current
|
||||
|
||||
return transition(id, 'startup_failed', {
|
||||
failureStage: FAILURE_STAGES.includes(stage) ? stage : 'require',
|
||||
failureReason: String(reason ?? 'unknown error').slice(0, 4000),
|
||||
})
|
||||
}
|
||||
|
||||
// Purge only (§2.5). A plain uninstall disables the module and keeps its row, so its
|
||||
// data survives and the admin panel can still show what was there.
|
||||
async function remove(id) {
|
||||
await db.remove(id)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
STATES,
|
||||
FAILURE_STAGES,
|
||||
ModuleStateError,
|
||||
list,
|
||||
get,
|
||||
recordInstalled,
|
||||
beginBoot,
|
||||
enable,
|
||||
disable,
|
||||
markStarted,
|
||||
markStartupFailed,
|
||||
remove,
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
// Per-user push-notification subscriptions (which streams a user opted into;
|
||||
// applied to every device they register). The catalog is config/notificationStreams.
|
||||
// applied to every device they register). The catalog is core's plus every
|
||||
// installed module's, so it is read back through modules/registries rather than
|
||||
// from a config file (MODULE_SYSTEM.md §1.8).
|
||||
|
||||
const db = require('./notificationSubs.db')
|
||||
const { isValidStream } = require('../../config/notificationStreams')
|
||||
const { isValidStream } = require('../../modules/registries')
|
||||
|
||||
const getForUser = async (userId) => (await db.listByUser(userId)).map((r) => r.stream_id)
|
||||
|
||||
|
||||
@@ -22,4 +22,12 @@ async function seedDefault(key, value) {
|
||||
await query('INSERT IGNORE INTO settings (`key`, value) VALUES (?, ?)', [key, value])
|
||||
}
|
||||
|
||||
module.exports = { getAll, get, set, seedDefault }
|
||||
// Delete a settings row. "Reset to defaults" for the theming/nav keys is the
|
||||
// *absence* of a row, not a stored copy of the defaults — see
|
||||
// docs/website/THEMING_AND_NAV.md §2. Deleting a key that was never set is a
|
||||
// no-op, so reset is idempotent.
|
||||
async function remove(key) {
|
||||
await query('DELETE FROM settings WHERE `key` = ?', [key])
|
||||
}
|
||||
|
||||
module.exports = { getAll, get, set, seedDefault, remove }
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
const settingsDb = require('./settings.db')
|
||||
const brand = require('../../config/brand')
|
||||
const { parseJsonSetting } = require('../../utils/settingsJson')
|
||||
const { resolveThemeTokens } = require('../../utils/themeResolve')
|
||||
const { resolveBrandAssets } = require('../../utils/brandAssets')
|
||||
|
||||
// Keys safe to expose on the public site.
|
||||
const PUBLIC_KEYS = [
|
||||
@@ -10,8 +13,27 @@ const PUBLIC_KEYS = [
|
||||
'contact_email',
|
||||
'site_title',
|
||||
'hero_layout', // portal hero composition (JSON). Draft key stays admin-only.
|
||||
'theme_visual', // preset/custom colors, fonts, radii (JSON). See THEMING_AND_NAV.md §6.1.
|
||||
'brand_assets', // uploaded logo/hero/favicon overrides (JSON). §6.3.
|
||||
'nav_public', // public site nav overrides (JSON). §6.4.
|
||||
]
|
||||
|
||||
// Admin-configurable theming & navigation (docs/website/THEMING_AND_NAV.md).
|
||||
// All five are JSON strings and all five are ABSENT by default — no migration
|
||||
// seeds them. Absence of the row, not an empty value, is what makes a surface
|
||||
// fall back to BRAND_* env / the hardcoded theme.css / the hardcoded NAV arrays.
|
||||
//
|
||||
// nav_admin and nav_player are deliberately not public: an anonymous visitor has
|
||||
// no use for either, and the admin nav's labels describe the shape of the admin
|
||||
// surface. They are read by their owners through GET /api/v1/settings/nav (§4.2).
|
||||
const THEMING_KEYS = ['theme_visual', 'brand_assets', 'nav_public', 'nav_admin', 'nav_player']
|
||||
|
||||
// Keys a reset may delete. An explicit allowlist, not "any key": DELETE on an
|
||||
// arbitrary key would let a bad request drop site_mode or the uo-link config,
|
||||
// whose absence means something else entirely. hero_layout_draft is included
|
||||
// because discarding a draft is the same operation.
|
||||
const DELETABLE_KEYS = [...THEMING_KEYS, 'hero_layout_draft']
|
||||
|
||||
// Player self-registration mode. Stored under the 'player_registration' key.
|
||||
// NOTE: the raw value is never exposed publicly — getPublic() derives boolean
|
||||
// availability flags from it instead (see below).
|
||||
@@ -70,6 +92,24 @@ async function isMobileAppLinksEnabled() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This instance's name, resolved exactly as `getPublic().brand.name` resolves it —
|
||||
* the admin-editable site title wins over BRAND_NAME. Anything that has to *speak*
|
||||
* the instance's name outside the settings payload must use this rather than
|
||||
* `brand.name`, or an install that set only the site title gets two different names
|
||||
* on two different pages.
|
||||
*
|
||||
* Never throws: a name is always better than an error, so a DB fault falls back to
|
||||
* the env value.
|
||||
*/
|
||||
async function getInstanceName() {
|
||||
try {
|
||||
return (await settingsDb.get('site_title')) || brand.name
|
||||
} catch {
|
||||
return brand.name
|
||||
}
|
||||
}
|
||||
|
||||
async function get(key) {
|
||||
return settingsDb.get(key)
|
||||
}
|
||||
@@ -78,6 +118,10 @@ async function set(key, value, updatedBy = null) {
|
||||
return settingsDb.set(key, value, updatedBy)
|
||||
}
|
||||
|
||||
async function remove(key) {
|
||||
return settingsDb.remove(key)
|
||||
}
|
||||
|
||||
async function setMany(obj, updatedBy = null) {
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
await settingsDb.set(key, value, updatedBy)
|
||||
@@ -106,9 +150,29 @@ async function getPublic() {
|
||||
// the final say when the call is made). Lets the portal show/hide the form.
|
||||
const gsMode = GAME_SIGNUP_MODES.includes(all[GAME_SIGNUP_KEY]) ? all[GAME_SIGNUP_KEY] : 'disabled'
|
||||
out.gameAccountSignup = GAME_SIGNUP_OFFER.includes(gsMode)
|
||||
// Instance branding (BRAND_* env defaults). The two admin-editable settings —
|
||||
// site title and contact email — override the env value when set, so existing
|
||||
// installs keep their DB-configured name; everything else comes from env.
|
||||
// The effective CSS custom properties for the admin's theme, or absent when
|
||||
// no theme_visual row exists (or nothing in it was usable). The SPA writes
|
||||
// these onto <html>; absence means it writes nothing and theme.css's :root
|
||||
// stands, which is what keeps an untouched instance byte-for-byte as today.
|
||||
// Resolution — :root ← preset ← custom — happens here rather than in CSS so
|
||||
// there is one authority and brand.accent below can report the same value the
|
||||
// site actually paints. See THEMING_AND_NAV.md §6.
|
||||
const theme = resolveThemeTokens(all.theme_visual)
|
||||
if (theme) out.theme = theme
|
||||
// Uploaded brand-asset overrides (§6.3), resolved here so every consumer of
|
||||
// the brand block — the SPA, the Android app, the Discord bot — picks them up
|
||||
// through the one contract. Forgiving on read like the theme: a slot holding
|
||||
// something we would not emit as a URL is dropped and its neighbours kept.
|
||||
const brandAssets = resolveBrandAssets(parseJsonSetting(all.brand_assets))
|
||||
// Instance branding (BRAND_* env defaults). The admin-editable settings —
|
||||
// site title, contact email, and now the theme accent and uploaded assets —
|
||||
// override the env value when set, so existing installs keep their
|
||||
// DB-configured name; everything else comes from env.
|
||||
//
|
||||
// brand.accent is a CROSS-REPO CONTRACT: the Android app themes its whole
|
||||
// Material palette from it (BrandDto → RunicGatewayTheme) and the Discord bot
|
||||
// colors its embeds from it. Resolving the effective accent here is what lets
|
||||
// both track admin theming with no client change.
|
||||
out.brand = {
|
||||
name: out.site_title || brand.name,
|
||||
shortName: brand.shortName,
|
||||
@@ -116,10 +180,10 @@ async function getPublic() {
|
||||
description: brand.description,
|
||||
contactEmail: out.contact_email || brand.contactEmail,
|
||||
url: brand.url,
|
||||
accent: brand.accent,
|
||||
logo: brand.logo,
|
||||
hero: brand.hero,
|
||||
favicon: brand.favicon,
|
||||
accent: theme?.['--accent'] || brand.accent,
|
||||
logo: brandAssets.logo || brand.logo,
|
||||
hero: brandAssets.hero || brand.hero,
|
||||
favicon: brandAssets.favicon || brand.favicon,
|
||||
}
|
||||
// Push-notification relay (M7). The client-facing ntfy base URL the app's
|
||||
// embedded distributor registers its device topic against; null when push is
|
||||
@@ -137,6 +201,41 @@ async function getPublic() {
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* What the HTML shell needs, resolved exactly as getPublic() resolves it: the
|
||||
* effective favicon and logo, plus the theme token map for the boot <style>
|
||||
* block. Kept here rather than in utils/htmlShell.js so there is one authority
|
||||
* for "which asset wins", and so the shell can never disagree with the payload
|
||||
* the SPA fetches a moment later.
|
||||
*
|
||||
* Throws on a DB fault — the caller (utils/htmlShell.js) decides what a failure
|
||||
* means for the page, and for it the answer is "serve the env-only shell".
|
||||
*
|
||||
* @returns {Promise<{logo: string, favicon: string, theme: object|null}>}
|
||||
*/
|
||||
async function getShellBrand() {
|
||||
const all = await getAll()
|
||||
const assets = resolveBrandAssets(parseJsonSetting(all.brand_assets))
|
||||
return {
|
||||
logo: assets.logo || brand.logo,
|
||||
favicon: assets.favicon || brand.favicon,
|
||||
theme: resolveThemeTokens(all.theme_visual),
|
||||
}
|
||||
}
|
||||
|
||||
// The two nav-override keys their own audiences need but cannot read from
|
||||
// GET /admin/settings (admin-only, while AdminLayout renders for editors and
|
||||
// moderators and PlayerPortalLayout renders for players — THEMING_AND_NAV.md
|
||||
// §4.2). Values are returned as stored: raw JSON strings, or null when the
|
||||
// admin never overrode that nav.
|
||||
async function getNav() {
|
||||
const all = await getAll()
|
||||
return {
|
||||
nav_admin: all.nav_admin ?? null,
|
||||
nav_player: all.nav_player ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
// The client-facing ntfy base URL (no trailing slash), or null when unset.
|
||||
function publicNtfyUrl() {
|
||||
const explicit = (process.env.NTFY_PUBLIC_URL || '').trim()
|
||||
@@ -151,10 +250,16 @@ function publicNtfyUrl() {
|
||||
module.exports = {
|
||||
get,
|
||||
set,
|
||||
remove,
|
||||
setMany,
|
||||
getAll,
|
||||
getPublic,
|
||||
getShellBrand,
|
||||
getNav,
|
||||
getInstanceName,
|
||||
PUBLIC_KEYS,
|
||||
THEMING_KEYS,
|
||||
DELETABLE_KEYS,
|
||||
REGISTRATION_KEY,
|
||||
REGISTRATION_MODES,
|
||||
getRegistrationMode,
|
||||
|
||||
@@ -192,6 +192,185 @@ async function clearPending() {
|
||||
return query('DELETE FROM shard_atlas_pending')
|
||||
}
|
||||
|
||||
// ── Reads (the public /atlas surface) ──────────────────────────────────────
|
||||
//
|
||||
// Every read here is a plain indexed query over ~7k rows and is served entirely
|
||||
// from MariaDB: the atlas is static shard content, so nothing on this path
|
||||
// touches the sidecar and nothing degrades when the shard is down.
|
||||
//
|
||||
// A facet filter is expressed as EXISTS over the points, never as a JSON path
|
||||
// built from caller input. `shard_spawn_creatures.facets` is a JSON object keyed
|
||||
// by facet name, and matching a key means either concatenating the name into a
|
||||
// path or handing it to JSON_SEARCH — whose search string treats `%` and `_` as
|
||||
// wildcards, so `?facet=%` would quietly match everything. The join is exact and
|
||||
// uses the indexes that already exist.
|
||||
const CREATURE_FACET_EXISTS = `EXISTS (
|
||||
SELECT 1 FROM shard_spawn_point_types t
|
||||
JOIN shard_spawn_points p ON p.id = t.point_id
|
||||
WHERE t.slug = c.slug AND p.facet = ?
|
||||
)`
|
||||
|
||||
// Build the WHERE for a creature search. `q` is a substring match on the display
|
||||
// name — a LIKE scan, which is free at ~800 rows and, unlike FULLTEXT, has no
|
||||
// minimum token length to break a search for "orc".
|
||||
function creatureWhere({ q, facet }) {
|
||||
const where = []
|
||||
const params = []
|
||||
if (q) {
|
||||
where.push('c.name LIKE ?')
|
||||
params.push(`%${q}%`)
|
||||
}
|
||||
if (facet) {
|
||||
where.push(CREATURE_FACET_EXISTS)
|
||||
params.push(facet)
|
||||
}
|
||||
return { sql: where.length ? `WHERE ${where.join(' AND ')}` : '', params }
|
||||
}
|
||||
|
||||
async function countCreatures({ q = '', facet = '' } = {}) {
|
||||
const { sql, params } = creatureWhere({ q, facet })
|
||||
const rows = await query(`SELECT COUNT(*) AS n FROM shard_spawn_creatures c ${sql}`, params)
|
||||
return rows[0] ? Number(rows[0].n) : 0
|
||||
}
|
||||
|
||||
function listCreatures({ q = '', facet = '', limit = 50, offset = 0 } = {}) {
|
||||
const { sql, params } = creatureWhere({ q, facet })
|
||||
return query(
|
||||
`SELECT c.slug, c.name, c.total, c.points, c.facets, c.art
|
||||
FROM shard_spawn_creatures c
|
||||
${sql}
|
||||
ORDER BY c.total DESC, c.name ASC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[...params, limit, offset],
|
||||
)
|
||||
}
|
||||
|
||||
async function getCreature(slug) {
|
||||
const rows = await query(
|
||||
'SELECT slug, name, total, points, facets, art FROM shard_spawn_creatures WHERE slug = ?',
|
||||
[slug],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a creature spawns, grouped by resolved place.
|
||||
*
|
||||
* This is the answer the atlas exists to give — "lizardman → Shrines,
|
||||
* Isamu-Jima, Yew" — so it is aggregated in SQL rather than by summing 6,455
|
||||
* point rows in Node.
|
||||
*/
|
||||
function listCreaturePlaces(slug, { facet = '' } = {}) {
|
||||
const params = [slug]
|
||||
let facetSql = ''
|
||||
if (facet) {
|
||||
facetSql = 'AND p.facet = ?'
|
||||
params.push(facet)
|
||||
}
|
||||
return query(
|
||||
`SELECT p.facet, p.label, COUNT(*) AS spawners, SUM(t.max_count) AS max_alive
|
||||
FROM shard_spawn_point_types t
|
||||
JOIN shard_spawn_points p ON p.id = t.point_id
|
||||
WHERE t.slug = ? ${facetSql}
|
||||
GROUP BY p.facet, p.label
|
||||
ORDER BY spawners DESC, p.facet ASC, p.label ASC`,
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
/** The individual spawners for a creature, newest-largest first. Bounded. */
|
||||
function listCreaturePoints(slug, { facet = '', limit = 200 } = {}) {
|
||||
const params = [slug]
|
||||
let facetSql = ''
|
||||
if (facet) {
|
||||
facetSql = 'AND p.facet = ?'
|
||||
params.push(facet)
|
||||
}
|
||||
params.push(limit)
|
||||
return query(
|
||||
`SELECT p.id, p.facet, p.name, p.x, p.y, p.width, p.height, p.spawn_range,
|
||||
p.min_delay, p.max_delay, p.tod_start, p.tod_end, p.tod_mode,
|
||||
p.region, p.landmark, p.label, t.max_count
|
||||
FROM shard_spawn_point_types t
|
||||
JOIN shard_spawn_points p ON p.id = t.point_id
|
||||
WHERE t.slug = ? ${facetSql}
|
||||
ORDER BY t.max_count DESC, p.facet ASC, p.label ASC, p.id ASC
|
||||
LIMIT ?`,
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
/** Every other creature sharing a spawner with this one. */
|
||||
function listCreatureCompanions(slug, { limit = 24 } = {}) {
|
||||
return query(
|
||||
`SELECT o.slug, c.name, COUNT(*) AS shared
|
||||
FROM shard_spawn_point_types t
|
||||
JOIN shard_spawn_point_types o ON o.point_id = t.point_id AND o.slug <> t.slug
|
||||
JOIN shard_spawn_creatures c ON c.slug = o.slug
|
||||
WHERE t.slug = ?
|
||||
GROUP BY o.slug, c.name
|
||||
ORDER BY shared DESC, c.name ASC
|
||||
LIMIT ?`,
|
||||
[slug, limit],
|
||||
)
|
||||
}
|
||||
|
||||
function listRegions({ facet = '', q = '' } = {}) {
|
||||
const where = []
|
||||
const params = []
|
||||
if (facet) {
|
||||
where.push('facet = ?')
|
||||
params.push(facet)
|
||||
}
|
||||
if (q) {
|
||||
where.push('name LIKE ?')
|
||||
params.push(`%${q}%`)
|
||||
}
|
||||
return query(
|
||||
`SELECT facet, name, type, priority, parent, rects
|
||||
FROM shard_regions
|
||||
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
|
||||
ORDER BY facet ASC, name ASC`,
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
function listLandmarks({ facet = '', q = '' } = {}) {
|
||||
const where = []
|
||||
const params = []
|
||||
if (facet) {
|
||||
where.push('facet = ?')
|
||||
params.push(facet)
|
||||
}
|
||||
if (q) {
|
||||
where.push('(name LIKE ? OR grp LIKE ?)')
|
||||
params.push(`%${q}%`, `%${q}%`)
|
||||
}
|
||||
return query(
|
||||
`SELECT facet, name, grp, x, y, z
|
||||
FROM shard_landmarks
|
||||
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
|
||||
ORDER BY facet ASC, grp ASC, name ASC`,
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
function listChampions({ facet = '' } = {}) {
|
||||
const params = []
|
||||
let where = ''
|
||||
if (facet) {
|
||||
where = 'WHERE facet = ?'
|
||||
params.push(facet)
|
||||
}
|
||||
return query(
|
||||
`SELECT slug, name, grp, type, random_type, facet, x, y, z, radius, label
|
||||
FROM shard_champion_spawns
|
||||
${where}
|
||||
ORDER BY facet ASC, name ASC`,
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
replaceAtlas,
|
||||
getMeta,
|
||||
@@ -199,4 +378,13 @@ module.exports = {
|
||||
getPending,
|
||||
setPending,
|
||||
clearPending,
|
||||
countCreatures,
|
||||
listCreatures,
|
||||
getCreature,
|
||||
listCreaturePlaces,
|
||||
listCreaturePoints,
|
||||
listCreatureCompanions,
|
||||
listRegions,
|
||||
listLandmarks,
|
||||
listChampions,
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ const settings = require('../settings/settings.model')
|
||||
const { slugify } = require('../../utils/spawnAtlasParse')
|
||||
const {
|
||||
AtlasSourceError,
|
||||
PARSER_VERSION,
|
||||
buildAtlas,
|
||||
hashSources,
|
||||
sameSources,
|
||||
@@ -120,6 +121,14 @@ async function applyAtlas(atlas) {
|
||||
* `force` skips the hash check (an admin asking for a reimport) and `approve`
|
||||
* additionally accepts facet loss (an admin approving a staged refresh).
|
||||
*/
|
||||
/**
|
||||
* Was the loaded atlas built by THIS parser?
|
||||
*
|
||||
* An atlas imported before `parserVersion` existed reports undefined, which is
|
||||
* correctly "no" — those are exactly the ones carrying the old readings.
|
||||
*/
|
||||
const currentParser = (meta) => meta?.parserVersion === PARSER_VERSION
|
||||
|
||||
async function refresh({ force = false, approve = false, path: pathOverride = '' } = {}) {
|
||||
// An explicit override wins outright — it is a one-off "use this tree", and it
|
||||
// must not be silently overruled by the configured path the way an env default
|
||||
@@ -142,7 +151,11 @@ async function refresh({ force = false, approve = false, path: pathOverride = ''
|
||||
? Object.fromEntries(Object.entries(meta.source).map(([label, v]) => [label, v.sha256]))
|
||||
: null
|
||||
|
||||
if (!force && sameSources(hashes, loaded)) {
|
||||
// Two things make a loaded atlas stale: the tree changed, or the PARSER did.
|
||||
// Only checking the tree would strand an install whose maps never change on
|
||||
// whatever an older build derived — a corrected parse would ship and never
|
||||
// reach the data.
|
||||
if (!force && sameSources(hashes, loaded) && currentParser(meta)) {
|
||||
return { status: 'unchanged', path: root }
|
||||
}
|
||||
|
||||
@@ -227,7 +240,9 @@ async function status({ path: pathOverride = '' } = {}) {
|
||||
const loaded = meta?.source
|
||||
? Object.fromEntries(Object.entries(meta.source).map(([l, v]) => [l, v.sha256]))
|
||||
: null
|
||||
drift = !sameSources(hashes, loaded)
|
||||
// Same question `refresh` asks: an import picks something up when either
|
||||
// the tree or the parser has moved on.
|
||||
drift = !sameSources(hashes, loaded) || !currentParser(meta)
|
||||
} catch {
|
||||
treeReadable = false
|
||||
}
|
||||
@@ -282,6 +297,173 @@ async function refreshOnBoot() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reads ──────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// The shapes the /public/atlas endpoints serve. Rows are camelCased here rather
|
||||
// than in the controller, for the same reason shardState does it: the column
|
||||
// names are an implementation detail of the import, and the browser contract
|
||||
// should not move when a column is renamed.
|
||||
|
||||
const jsonOr = (value, fallback) => {
|
||||
if (value == null) return fallback
|
||||
if (typeof value !== 'string') return value
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
const shapeCreature = (row) => ({
|
||||
slug: row.slug,
|
||||
name: row.name,
|
||||
// `total` is the summed MaxCount across every spawner (how many can be alive
|
||||
// at once); `points` is how many spawners mention it. They answer different
|
||||
// questions and the UI shows both.
|
||||
total: row.total,
|
||||
points: row.points,
|
||||
facets: jsonOr(row.facets, {}),
|
||||
art: row.art || null,
|
||||
})
|
||||
|
||||
const shapePlace = (row) => ({
|
||||
facet: row.facet,
|
||||
label: row.label,
|
||||
spawners: Number(row.spawners) || 0,
|
||||
maxAlive: Number(row.max_alive) || 0,
|
||||
})
|
||||
|
||||
const shapePoint = (row) => ({
|
||||
id: row.id,
|
||||
facet: row.facet,
|
||||
name: row.name || null,
|
||||
x: row.x,
|
||||
y: row.y,
|
||||
width: row.width,
|
||||
height: row.height,
|
||||
range: row.spawn_range,
|
||||
maxCount: row.max_count,
|
||||
minDelay: row.min_delay,
|
||||
maxDelay: row.max_delay,
|
||||
todStart: row.tod_start,
|
||||
todEnd: row.tod_end,
|
||||
todMode: row.tod_mode,
|
||||
region: row.region || null,
|
||||
landmark: row.landmark || null,
|
||||
label: row.label,
|
||||
})
|
||||
|
||||
/**
|
||||
* Paginated creature search. Returns the page plus the unpaginated total, so
|
||||
* the UI can say "showing 50 of 800" without a second round trip.
|
||||
*/
|
||||
async function searchCreatures({ q = '', facet = '', limit = 50, offset = 0 } = {}) {
|
||||
const [rows, total] = await Promise.all([
|
||||
db.listCreatures({ q, facet, limit, offset }),
|
||||
db.countCreatures({ q, facet }),
|
||||
])
|
||||
return { total, limit, offset, creatures: rows.map(shapeCreature) }
|
||||
}
|
||||
|
||||
/**
|
||||
* One creature: its totals, the places it spawns (the aggregate the atlas
|
||||
* exists for), the individual spawners, and what else shares those spawners.
|
||||
*
|
||||
* `null` when the slug is unknown — the controller turns that into a 404.
|
||||
*/
|
||||
async function getCreature(slug, { facet = '', points = 200 } = {}) {
|
||||
const row = await db.getCreature(slug)
|
||||
if (!row) return null
|
||||
const [places, pointRows, alsoHere] = await Promise.all([
|
||||
db.listCreaturePlaces(slug, { facet }),
|
||||
db.listCreaturePoints(slug, { facet, limit: points }),
|
||||
db.listCreatureCompanions(slug),
|
||||
])
|
||||
return {
|
||||
...shapeCreature(row),
|
||||
places: places.map(shapePlace),
|
||||
// `spawners`, not `points`: shapeCreature already uses `points` for the
|
||||
// COUNT of spawners, and reusing the key for the list of them would make the
|
||||
// same field a number on the search route and an array here.
|
||||
spawners: pointRows.map(shapePoint),
|
||||
// Bounded by the query, so a creature on hundreds of spawners returns a page
|
||||
// rather than the world.
|
||||
spawnersTruncated: pointRows.length >= points,
|
||||
alsoHere: alsoHere.map((r) => ({
|
||||
slug: r.slug,
|
||||
name: r.name,
|
||||
shared: Number(r.shared) || 0,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
async function listRegions(opts = {}) {
|
||||
const rows = await db.listRegions(opts)
|
||||
return rows.map((r) => ({
|
||||
facet: r.facet,
|
||||
name: r.name,
|
||||
type: r.type || null,
|
||||
priority: r.priority,
|
||||
parent: r.parent || null,
|
||||
rects: jsonOr(r.rects, []),
|
||||
}))
|
||||
}
|
||||
|
||||
async function listLandmarks(opts = {}) {
|
||||
const rows = await db.listLandmarks(opts)
|
||||
return rows.map((r) => ({
|
||||
facet: r.facet,
|
||||
name: r.name,
|
||||
group: r.grp || null,
|
||||
x: r.x,
|
||||
y: r.y,
|
||||
z: r.z,
|
||||
}))
|
||||
}
|
||||
|
||||
async function listChampions(opts = {}) {
|
||||
const rows = await db.listChampions(opts)
|
||||
return rows.map((r) => ({
|
||||
slug: r.slug,
|
||||
name: r.name,
|
||||
group: r.grp || null,
|
||||
// '' on the wire means "randomised at activation"; `randomType` says so
|
||||
// explicitly rather than making the client infer it from an empty string.
|
||||
type: r.type || null,
|
||||
randomType: !!r.random_type,
|
||||
facet: r.facet,
|
||||
x: r.x,
|
||||
y: r.y,
|
||||
z: r.z,
|
||||
radius: r.radius,
|
||||
label: r.label || null,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* What is loaded: the facet list, the counts, and when it was imported.
|
||||
*
|
||||
* Deliberately does NOT report the source path, the per-file hashes or whether
|
||||
* a refresh is pending. Those describe the operator's filesystem, and this is a
|
||||
* public endpoint; the admin status route carries them instead.
|
||||
*/
|
||||
async function publicMeta() {
|
||||
const [meta, facets] = await Promise.all([
|
||||
db.getMeta().catch(() => null),
|
||||
db.getFacets().catch(() => []),
|
||||
])
|
||||
return {
|
||||
importedAt: meta?.importedAt ?? null,
|
||||
generatedAt: meta?.generatedAt ?? null,
|
||||
// The parse counts, not the row counts: `unresolvedPoints` is what lets the
|
||||
// page state its own placement accuracy instead of implying it is complete.
|
||||
counts: meta?.counts ?? null,
|
||||
facets,
|
||||
}
|
||||
}
|
||||
|
||||
const listFacets = () => db.getFacets()
|
||||
|
||||
module.exports = {
|
||||
refresh,
|
||||
refreshOnBoot,
|
||||
@@ -293,4 +475,11 @@ module.exports = {
|
||||
pointTypeRows,
|
||||
loadArtMap,
|
||||
SETTING_KEY,
|
||||
searchCreatures,
|
||||
getCreature,
|
||||
listRegions,
|
||||
listLandmarks,
|
||||
listChampions,
|
||||
listFacets,
|
||||
publicMeta,
|
||||
}
|
||||
|
||||
108
server/src/model/shardClilocs/shardClilocs.db.js
Normal file
108
server/src/model/shardClilocs/shardClilocs.db.js
Normal file
@@ -0,0 +1,108 @@
|
||||
const { pool, query } = require('../../utils/db')
|
||||
|
||||
// Raw SQL for the cliloc table. `shard_clilocs` is IMPORT-OWNED: `replaceAll`
|
||||
// empties and refills it inside one transaction, and nothing else in the
|
||||
// codebase writes to it. No foreign keys, consistent with every other shard_*
|
||||
// table.
|
||||
|
||||
const BATCH = 1000
|
||||
|
||||
/**
|
||||
* Replace the entire cliloc table in one transaction.
|
||||
*
|
||||
* All-or-nothing on purpose: a failed reload must leave the previous table
|
||||
* intact rather than a half-loaded one, because a partially-imported cliloc
|
||||
* table is indistinguishable from a complete one to anyone reading it — you
|
||||
* would just see some items named and some not, which is also what "no table at
|
||||
* all" looks like.
|
||||
*
|
||||
* `DELETE`, not `TRUNCATE` — `TRUNCATE` is DDL in MariaDB and implicitly
|
||||
* commits, which would defeat exactly that guarantee. (The same trap the spawn
|
||||
* atlas import documents; at ~123k rows `DELETE` is still well under a second.)
|
||||
*/
|
||||
async function replaceAll(entries, meta) {
|
||||
const conn = await pool.getConnection()
|
||||
try {
|
||||
await conn.beginTransaction()
|
||||
await conn.query('DELETE FROM shard_clilocs')
|
||||
|
||||
// Blank entries are dropped rather than stored. Roughly HALF of a real
|
||||
// cliloc table is empty strings — ids the client reserves and never uses —
|
||||
// and a row that resolves to no name is indistinguishable from no row at
|
||||
// all to every caller. Dropping them halves the table (123,490 → ~67,500)
|
||||
// and, more importantly, makes the binary and text imports converge on
|
||||
// identical content: the binary format carries the blanks explicitly and a
|
||||
// text export may or may not, depending on the tool.
|
||||
//
|
||||
// Later duplicates win. Merging across sources already happened upstream in
|
||||
// `readCliloc`, so in practice this collapses nothing — it is kept because
|
||||
// the plain format permits a repeated id WITHIN one file and the client's
|
||||
// own loader resolves it the same way (its dictionary assignment
|
||||
// overwrites). Without it, a file the game itself would load happily would
|
||||
// fail the batch insert on a primary-key collision.
|
||||
const byNumber = new Map()
|
||||
let blank = 0
|
||||
for (const entry of entries) {
|
||||
if (!Number.isInteger(entry.number)) continue
|
||||
if (String(entry.text ?? '').trim() === '') {
|
||||
blank++
|
||||
continue
|
||||
}
|
||||
byNumber.set(entry.number, entry)
|
||||
}
|
||||
|
||||
const rows = [...byNumber.values()].map((e) => [e.number, e.flag ?? 0, e.text])
|
||||
for (let i = 0; i < rows.length; i += BATCH) {
|
||||
await conn.batch('INSERT INTO shard_clilocs (number, flag, text) VALUES (?,?,?)', rows.slice(i, i + BATCH))
|
||||
}
|
||||
|
||||
await conn.query(
|
||||
'INSERT INTO shard_cliloc_meta (id, payload) VALUES (1, ?) ' +
|
||||
'ON DUPLICATE KEY UPDATE payload = VALUES(payload), imported_at = CURRENT_TIMESTAMP',
|
||||
[JSON.stringify({ ...meta, count: rows.length })],
|
||||
)
|
||||
|
||||
await conn.commit()
|
||||
return { count: rows.length, blank, duplicates: entries.length - blank - rows.length }
|
||||
} catch (err) {
|
||||
await conn.rollback().catch(() => {})
|
||||
throw err
|
||||
} finally {
|
||||
conn.release()
|
||||
}
|
||||
}
|
||||
|
||||
async function getMeta() {
|
||||
const rows = await query('SELECT payload, imported_at FROM shard_cliloc_meta WHERE id = 1')
|
||||
if (rows.length === 0) return null
|
||||
const payload = typeof rows[0].payload === 'string' ? JSON.parse(rows[0].payload) : rows[0].payload
|
||||
return { ...payload, importedAt: rows[0].imported_at }
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a batch of ids.
|
||||
*
|
||||
* Batched rather than one-at-a-time because every caller has a LIST: a character
|
||||
* sheet resolves a dozen equipment ids at once, and a page of marketplace
|
||||
* listings resolves fifty. `IN (...)` with generated placeholders keeps it one
|
||||
* round trip and one parameterized statement.
|
||||
*/
|
||||
async function lookup(numbers) {
|
||||
if (!Array.isArray(numbers) || numbers.length === 0) return []
|
||||
const ids = [...new Set(numbers.filter((n) => Number.isInteger(n)))]
|
||||
if (ids.length === 0) return []
|
||||
const placeholders = ids.map(() => '?').join(',')
|
||||
return query(`SELECT number, text FROM shard_clilocs WHERE number IN (${placeholders})`, ids)
|
||||
}
|
||||
|
||||
async function count() {
|
||||
const rows = await query('SELECT COUNT(*) AS n FROM shard_clilocs')
|
||||
return Number(rows[0]?.n) || 0
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
replaceAll,
|
||||
getMeta,
|
||||
lookup,
|
||||
count,
|
||||
}
|
||||
368
server/src/model/shardClilocs/shardClilocs.model.js
Normal file
368
server/src/model/shardClilocs/shardClilocs.model.js
Normal file
@@ -0,0 +1,368 @@
|
||||
const db = require('./shardClilocs.db')
|
||||
const settings = require('../settings/settings.model')
|
||||
const { displayText } = require('../../utils/clilocParse')
|
||||
const {
|
||||
ClilocFormatError,
|
||||
ClilocSourceError,
|
||||
PARSER_VERSION,
|
||||
hashSources,
|
||||
sameSources,
|
||||
missingSources,
|
||||
readCliloc,
|
||||
} = require('../../utils/clilocSource')
|
||||
const log = require('../../utils/logger')('shardClilocs')
|
||||
|
||||
// The cliloc table — UO's id → display-string map, refreshed from a file the
|
||||
// operator converts once from their own client.
|
||||
//
|
||||
// Why the site holds this at all: items on the wire carry a `LabelNumber`, not a
|
||||
// name. `char.profile.equipment` has always sent `cliloc`, and every marketplace
|
||||
// listing sends one too. Without the table the UI can only print `id 1023721`
|
||||
// where the game prints "quarter staff".
|
||||
//
|
||||
// Two rules govern the boot path, both inherited from the spawn atlas:
|
||||
//
|
||||
// 1. **It never blocks startup.** No configured path, an unreadable file, a
|
||||
// wrong-format file, a database error — all caught and logged. The site
|
||||
// comes up either way, serving whatever table it already had (or none, in
|
||||
// which case the UI falls back to item ids exactly as it did before).
|
||||
// 2. **Nothing client-derived is committed.** The table is built from the
|
||||
// operator's own file at a configured path. The repo ships no strings.
|
||||
//
|
||||
// The table is built from a SET of sources — the converted client table plus
|
||||
// every operator-maintained overlay beside it — because shards edit items and
|
||||
// add new ones, and those carry cliloc ids no stock client table has. All of
|
||||
// them are re-read on every boot and hash-gated together, so adding one custom
|
||||
// item never means re-exporting a 5 MB client file. Later sources win.
|
||||
//
|
||||
// That set is also why this has the atlas's escalation, in a lighter form. A
|
||||
// single corrupt file fails the parse loudly, but a source that has simply
|
||||
// VANISHED parses perfectly and imports a table quietly missing everything it
|
||||
// contributed — the same ambiguity (real change vs half-copied mount) the atlas
|
||||
// stages a facet removal for. So a disappearing source is refused and reported
|
||||
// rather than applied.
|
||||
//
|
||||
// It is lighter than the atlas's because it needs to be: the atlas stores a
|
||||
// pending decision in its own table and adds approve/reject endpoints, whereas
|
||||
// here the decision is a single boolean an admin passes to the import they were
|
||||
// already going to run. Re-parsing at approval time — the property that makes
|
||||
// the atlas store only the decision — is automatic when there is nothing stored.
|
||||
|
||||
const SETTING_KEY = 'cliloc_client_path'
|
||||
|
||||
/**
|
||||
* Where the converted cliloc file lives.
|
||||
*
|
||||
* The admin setting wins over the environment so an operator can repoint it
|
||||
* without a redeploy, matching how the rest of the shard integration is
|
||||
* admin-managed rather than env-configured. `UO_CLIENT_PATH` remains as the
|
||||
* deploy-time default, since the path usually describes a mount the deployment
|
||||
* sets up.
|
||||
*/
|
||||
async function getClientPath() {
|
||||
try {
|
||||
const configured = await settings.get(SETTING_KEY)
|
||||
if (configured && String(configured).trim() !== '') return String(configured).trim()
|
||||
} catch {
|
||||
// Settings unavailable is not fatal — fall through to the env default.
|
||||
}
|
||||
const fromEnv = process.env.UO_CLIENT_PATH
|
||||
return fromEnv && fromEnv.trim() !== '' ? fromEnv.trim() : ''
|
||||
}
|
||||
|
||||
async function setClientPath(value, updatedBy = null) {
|
||||
const result = await settings.set(SETTING_KEY, String(value ?? '').trim(), updatedBy)
|
||||
invalidate()
|
||||
return result
|
||||
}
|
||||
|
||||
// ── Refresh ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Was the loaded table built by THIS parser? */
|
||||
const currentParser = (meta) => meta?.parserVersion === PARSER_VERSION
|
||||
|
||||
/**
|
||||
* Refresh the cliloc table from the configured file.
|
||||
*
|
||||
* Returns a result describing what happened rather than throwing, so the caller
|
||||
* — including the boot path — can log it and move on:
|
||||
*
|
||||
* `skipped` no path configured
|
||||
* `unavailable` path configured but missing / unreadable / not a cliloc file
|
||||
* `unchanged` source hashes match the loaded table; nothing parsed
|
||||
* `imported` parsed and applied
|
||||
* `needsReview` a previously-present source has vanished; NOT applied
|
||||
* `failed` parsed or applied and something went wrong
|
||||
*
|
||||
* `force` skips the hash check (an admin asking for a reimport). `approve`
|
||||
* additionally accepts a vanished source.
|
||||
*/
|
||||
async function refresh({ force = false, approve = false, path: pathOverride = '' } = {}) {
|
||||
// An explicit override wins outright — a one-off "use this file", which must
|
||||
// not be silently overruled by the configured path the way an env default is.
|
||||
const configured = pathOverride.trim() !== '' ? pathOverride.trim() : await getClientPath()
|
||||
if (configured === '') return { status: 'skipped', reason: 'no cliloc path configured' }
|
||||
|
||||
let fingerprint
|
||||
try {
|
||||
fingerprint = hashSources(configured)
|
||||
} catch (err) {
|
||||
if (err instanceof ClilocSourceError) {
|
||||
return { status: 'unavailable', reason: err.message, code: err.code, path: configured }
|
||||
}
|
||||
return { status: 'failed', reason: err.message, path: configured }
|
||||
}
|
||||
|
||||
const meta = await db.getMeta().catch(() => null)
|
||||
|
||||
// Two things make a loaded table stale: any source changed, or the PARSER did.
|
||||
// Only checking the sources would strand an install whose client never patches
|
||||
// on whatever an older build derived.
|
||||
if (!force && sameSources(fingerprint.hashes, meta?.hashes) && currentParser(meta)) {
|
||||
return {
|
||||
status: 'unchanged',
|
||||
path: configured,
|
||||
file: fingerprint.file,
|
||||
count: meta.count ?? null,
|
||||
customCount: fingerprint.customCount,
|
||||
}
|
||||
}
|
||||
|
||||
// A source that was there last import and is not there now is refused, not
|
||||
// applied — an unmounted volume and a deliberate deletion look identical from
|
||||
// here, and the wrong guess silently drops every name that file contributed.
|
||||
const gone = missingSources(fingerprint.hashes, meta?.hashes)
|
||||
if (gone.length > 0 && !approve) {
|
||||
return {
|
||||
status: 'needsReview',
|
||||
reason: `${gone.length} previously-loaded cliloc source(s) are missing; the existing table is unchanged`,
|
||||
missingSources: gone,
|
||||
path: configured,
|
||||
file: fingerprint.file,
|
||||
}
|
||||
}
|
||||
|
||||
let parsed
|
||||
try {
|
||||
parsed = readCliloc(configured)
|
||||
} catch (err) {
|
||||
if (err instanceof ClilocFormatError || err instanceof ClilocSourceError) {
|
||||
return { status: 'unavailable', reason: err.message, code: err.code, path: configured }
|
||||
}
|
||||
return { status: 'failed', reason: err.message, path: configured }
|
||||
}
|
||||
|
||||
try {
|
||||
const applied = await db.replaceAll(parsed.entries, parsed.source)
|
||||
invalidate()
|
||||
return {
|
||||
status: 'imported',
|
||||
path: configured,
|
||||
file: parsed.source.file,
|
||||
count: applied.count,
|
||||
parsed: parsed.entries.length,
|
||||
blank: applied.blank,
|
||||
// Per-source breakdown: how many entries each file contributed and how
|
||||
// many of them overrode something already merged. An operator who adds an
|
||||
// overlay wants to see it took effect, and "overrode: 0" on a file meant
|
||||
// to re-label stock items says it did not.
|
||||
sources: parsed.source.sources,
|
||||
acceptedMissing: gone.length > 0 ? gone : undefined,
|
||||
}
|
||||
} catch (err) {
|
||||
return { status: 'failed', reason: err.message, path: configured }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot hook. Best-effort by contract: it logs and returns, never throws, so a
|
||||
* missing or malformed cliloc file can never stop the site coming up.
|
||||
*/
|
||||
async function refreshOnBoot() {
|
||||
try {
|
||||
const result = await refresh()
|
||||
switch (result.status) {
|
||||
case 'imported':
|
||||
log.info('cliloc table refreshed', {
|
||||
file: result.file,
|
||||
count: result.count,
|
||||
overlays: (result.sources || []).filter((s) => s.kind === 'custom').length,
|
||||
})
|
||||
break
|
||||
case 'needsReview':
|
||||
log.warn(
|
||||
'cliloc refresh staged for admin review — a previously-loaded source is missing; ' +
|
||||
'the existing table is unchanged',
|
||||
{ missing: result.missingSources },
|
||||
)
|
||||
break
|
||||
case 'unavailable':
|
||||
// Deliberately a warning, not an error: an operator who has not supplied
|
||||
// a cliloc file is in a supported state (the UI shows item ids), and the
|
||||
// most common cause — pointing at the client's own compressed file —
|
||||
// needs the reason spelled out rather than a stack trace.
|
||||
log.warn('cliloc source unavailable (item names will show as ids)', {
|
||||
reason: result.reason,
|
||||
code: result.code,
|
||||
path: result.path,
|
||||
})
|
||||
break
|
||||
case 'failed':
|
||||
log.warn('cliloc refresh failed', { reason: result.reason })
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
return result
|
||||
} catch (err) {
|
||||
log.warn('cliloc refresh errored', { error: err.message })
|
||||
return { status: 'failed', reason: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
/** Everything the admin panel needs to describe cliloc state. */
|
||||
async function status({ path: pathOverride = '' } = {}) {
|
||||
const configured = pathOverride.trim() !== '' ? pathOverride.trim() : await getClientPath()
|
||||
const meta = await db.getMeta().catch(() => null)
|
||||
const loaded = await db.count().catch(() => 0)
|
||||
|
||||
let fileReadable = false
|
||||
let file = null
|
||||
let drift = null
|
||||
let problem = null
|
||||
let code = null
|
||||
let sources = []
|
||||
let missing = []
|
||||
if (configured !== '') {
|
||||
try {
|
||||
const fingerprint = hashSources(configured)
|
||||
fileReadable = true
|
||||
file = fingerprint.file
|
||||
sources = Object.keys(fingerprint.hashes)
|
||||
missing = missingSources(fingerprint.hashes, meta?.hashes)
|
||||
// A compressed file is readable but not importable, and the panel has to
|
||||
// say so HERE — otherwise pointing at an unconverted client directory
|
||||
// reports a healthy file with pending drift ("ready to import") and the
|
||||
// operator only finds out when the import fails. `drift` stays null
|
||||
// because comparing hashes with an unusable file answers nothing.
|
||||
if (fingerprint.compressed) {
|
||||
problem =
|
||||
'This is a compressed (Mythic-format) cliloc file, which the site cannot read. ' +
|
||||
'Convert it to the plain format first — see docs/website/CLILOCS.md.'
|
||||
code = 'COMPRESSED'
|
||||
} else {
|
||||
drift = !sameSources(fingerprint.hashes, meta?.hashes) || !currentParser(meta)
|
||||
}
|
||||
} catch (err) {
|
||||
fileReadable = false
|
||||
problem = err.message
|
||||
code = err.code ?? null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
configured: configured !== '',
|
||||
path: configured,
|
||||
file,
|
||||
fileReadable,
|
||||
problem,
|
||||
code,
|
||||
drift,
|
||||
count: loaded,
|
||||
// Every source found now (base first, then overlays), what each contributed
|
||||
// at the last import, and any that have since vanished — which is the state
|
||||
// an import will refuse without `approve`.
|
||||
sources,
|
||||
loadedSources: meta?.sources ?? null,
|
||||
missingSources: missing,
|
||||
importedAt: meta?.importedAt ?? null,
|
||||
sourceBytes: meta?.bytes ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Lookup ─────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Resolution happens SERVER-SIDE, not in the browser. Two reasons: the table is
|
||||
// ~123k rows and shipping it to a client would dwarf every page that uses it,
|
||||
// and the Android app consumes the same JSON and would otherwise need its own
|
||||
// copy. Callers get names, not ids-plus-a-table.
|
||||
|
||||
// A small write-through cache in front of the table. Item ids repeat heavily —
|
||||
// one page of listings is mostly the same few hundred clilocs, and a character
|
||||
// sheet re-resolves the same gear on every view — so this turns the steady state
|
||||
// into zero queries. Capped so a pathological caller cannot grow it without
|
||||
// bound; on overflow it is dropped wholesale rather than evicted entry-by-entry,
|
||||
// which is cheap and correct for a table that only changes on reimport.
|
||||
const CACHE_MAX = 20000
|
||||
let cache = new Map()
|
||||
|
||||
function invalidate() {
|
||||
cache = new Map()
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a batch of cliloc ids to display strings.
|
||||
*
|
||||
* Returns a `Map<number, string>` holding only the ids that resolved to
|
||||
* something displayable — an id with no row, or one whose text is nothing but
|
||||
* interpolated arguments we do not have, is simply absent. Callers fall back to
|
||||
* whatever they had (the item id), so "missing" and "unnamed" collapse into one
|
||||
* branch at the call site.
|
||||
*
|
||||
* Never throws: a cliloc lookup is decoration on someone's character sheet, and
|
||||
* a database blip must not fail the sheet.
|
||||
*/
|
||||
async function resolveMany(numbers) {
|
||||
const out = new Map()
|
||||
if (!Array.isArray(numbers)) return out
|
||||
|
||||
const wanted = [...new Set(numbers.filter((n) => Number.isInteger(n) && n > 0))]
|
||||
if (wanted.length === 0) return out
|
||||
|
||||
const missing = []
|
||||
for (const number of wanted) {
|
||||
if (cache.has(number)) {
|
||||
const hit = cache.get(number)
|
||||
if (hit !== '') out.set(number, hit)
|
||||
} else {
|
||||
missing.push(number)
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.length > 0) {
|
||||
try {
|
||||
const rows = await db.lookup(missing)
|
||||
const found = new Map(rows.map((r) => [Number(r.number), displayText(r.text)]))
|
||||
if (cache.size + missing.length > CACHE_MAX) invalidate()
|
||||
for (const number of missing) {
|
||||
// Cache the miss too ('' meaning "no usable name"), so an id absent from
|
||||
// the table does not re-query on every page view.
|
||||
const text = found.get(number) ?? ''
|
||||
cache.set(number, text)
|
||||
if (text !== '') out.set(number, text)
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('cliloc lookup failed', { message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/** Single-id convenience. Returns `null` when there is no usable name. */
|
||||
async function resolve(number) {
|
||||
const found = await resolveMany([number])
|
||||
return found.get(number) ?? null
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SETTING_KEY,
|
||||
getClientPath,
|
||||
setClientPath,
|
||||
refresh,
|
||||
refreshOnBoot,
|
||||
status,
|
||||
resolveMany,
|
||||
resolve,
|
||||
invalidate,
|
||||
}
|
||||
299
server/src/model/shardMarket/shardMarket.db.js
Normal file
299
server/src/model/shardMarket/shardMarket.db.js
Normal file
@@ -0,0 +1,299 @@
|
||||
const { pool, query } = require('../../utils/db')
|
||||
|
||||
// Raw SQL for the player-vendor market index (Protocol 3.0 vendor.listing).
|
||||
//
|
||||
// Two tables, both INGEST-OWNED: `shard_vendors` (one row per shop) and
|
||||
// `shard_vendor_items` (one row per priced listing). Nothing else in the codebase
|
||||
// writes to either. No foreign keys, consistent with every other shard_* table.
|
||||
|
||||
// Insert batch size for one vendor's listings. A shop is capped at
|
||||
// MarketMaxListings (250 by default) on the shard side, so in practice this is
|
||||
// one batch — it exists for the operator who raised that cap.
|
||||
const BATCH = 500
|
||||
|
||||
// LIKE wildcards in user input. `%` and `_` are not special to the parameterized
|
||||
// query — they are special to LIKE itself — so a search for "50% off" would
|
||||
// otherwise match everything containing "50" and a search for "_" would match
|
||||
// every single-character name. Escaped with a backslash, which is MariaDB's
|
||||
// default LIKE escape (no ESCAPE clause needed).
|
||||
const likeTerm = (q) => `%${String(q).replace(/[\\%_]/g, (c) => `\\${c}`)}%`
|
||||
|
||||
/**
|
||||
* Replace one vendor's whole row and listing set, in one transaction.
|
||||
*
|
||||
* Delete-then-insert rather than a diff, because the frame is AUTHORITATIVE for
|
||||
* that vendor: the shard's sweep only emits a shop whose contents, prices or
|
||||
* location moved, and when it does it sends the whole shop. Reconciling it item
|
||||
* by item would be more code for the same result and would leave sold items
|
||||
* behind on any path the reconciliation missed.
|
||||
*
|
||||
* All-or-nothing matters here for a specific reason: the two writes are "the
|
||||
* shop" and "what is in it", and a failure between them leaves a shop advertising
|
||||
* an inventory it no longer has (or none at all) — visibly wrong on the page, and
|
||||
* indistinguishable from a genuinely empty shop.
|
||||
*/
|
||||
async function replaceVendor(vendor, items) {
|
||||
const conn = await pool.getConnection()
|
||||
try {
|
||||
await conn.beginTransaction()
|
||||
|
||||
await conn.query(
|
||||
`INSERT INTO shard_vendors
|
||||
(serial, shop_name, owner_serial, owner_name, map, x, y, z, region, house,
|
||||
item_count, item_total, truncated, t)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON DUPLICATE KEY UPDATE shop_name = VALUES(shop_name), owner_serial = VALUES(owner_serial),
|
||||
owner_name = VALUES(owner_name), map = VALUES(map), x = VALUES(x), y = VALUES(y),
|
||||
z = VALUES(z), region = VALUES(region), house = VALUES(house),
|
||||
item_count = VALUES(item_count), item_total = VALUES(item_total),
|
||||
truncated = VALUES(truncated), t = VALUES(t),
|
||||
-- Touched explicitly rather than left to ON UPDATE CURRENT_TIMESTAMP:
|
||||
-- MariaDB does not fire that when every column is written back
|
||||
-- unchanged, and a shop that is re-published identically is still
|
||||
-- FRESHLY CONFIRMED. Without this the staleness banner would age a
|
||||
-- perfectly current shop forever.
|
||||
updated_at = CURRENT_TIMESTAMP`,
|
||||
[
|
||||
vendor.serial,
|
||||
vendor.shopName ?? null,
|
||||
vendor.ownerSerial ?? null,
|
||||
vendor.ownerName ?? null,
|
||||
vendor.map ?? null,
|
||||
Number.isFinite(vendor.x) ? vendor.x : null,
|
||||
Number.isFinite(vendor.y) ? vendor.y : null,
|
||||
Number.isFinite(vendor.z) ? vendor.z : null,
|
||||
vendor.region ?? null,
|
||||
vendor.house ?? null,
|
||||
items.length,
|
||||
Number.isFinite(vendor.itemTotal) ? vendor.itemTotal : items.length,
|
||||
vendor.truncated ? 1 : 0,
|
||||
Number.isFinite(vendor.t) ? vendor.t : null,
|
||||
],
|
||||
)
|
||||
|
||||
await conn.query('DELETE FROM shard_vendor_items WHERE vendor_serial = ?', [vendor.serial])
|
||||
|
||||
const rows = items.map((i) => [
|
||||
vendor.serial,
|
||||
i.serial,
|
||||
i.itemId,
|
||||
i.hue,
|
||||
i.amount,
|
||||
i.price,
|
||||
i.name,
|
||||
i.cliloc,
|
||||
i.displayName,
|
||||
i.child ? 1 : 0,
|
||||
])
|
||||
|
||||
for (let i = 0; i < rows.length; i += BATCH) {
|
||||
await conn.batch(
|
||||
`INSERT INTO shard_vendor_items
|
||||
(vendor_serial, serial, item_id, hue, amount, price, name, cliloc, display_name, child)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)`,
|
||||
rows.slice(i, i + BATCH),
|
||||
)
|
||||
}
|
||||
|
||||
await conn.commit()
|
||||
return { items: rows.length }
|
||||
} catch (err) {
|
||||
await conn.rollback().catch(() => {})
|
||||
throw err
|
||||
} finally {
|
||||
conn.release()
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop one vendor and its listings (vendor.listing.remove). */
|
||||
async function removeVendor(serial) {
|
||||
const conn = await pool.getConnection()
|
||||
try {
|
||||
await conn.beginTransaction()
|
||||
await conn.query('DELETE FROM shard_vendor_items WHERE vendor_serial = ?', [serial])
|
||||
await conn.query('DELETE FROM shard_vendors WHERE serial = ?', [serial])
|
||||
await conn.commit()
|
||||
} catch (err) {
|
||||
await conn.rollback().catch(() => {})
|
||||
throw err
|
||||
} finally {
|
||||
conn.release()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Search ─────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// The unit of a search RESULT is a listing, not a vendor: "who sells a vanquishing
|
||||
// kryss and for how much" is the question, and answering it per vendor would make
|
||||
// the caller flatten the shops back out. The vendor's columns ride along on the
|
||||
// join so a result row is self-contained.
|
||||
|
||||
function searchWhere({ q, minPrice, maxPrice, itemId, map, region }) {
|
||||
const where = ['i.price > 0']
|
||||
const params = []
|
||||
|
||||
if (q) {
|
||||
// Both the resolved display name and the item's own literal, because an item
|
||||
// with a player-set name (most of what is actually worth searching for on a
|
||||
// player-run shard) may have a generic cliloc.
|
||||
where.push('(i.display_name LIKE ? OR i.name LIKE ?)')
|
||||
params.push(likeTerm(q), likeTerm(q))
|
||||
}
|
||||
if (Number.isFinite(minPrice)) {
|
||||
where.push('i.price >= ?')
|
||||
params.push(minPrice)
|
||||
}
|
||||
if (Number.isFinite(maxPrice)) {
|
||||
where.push('i.price <= ?')
|
||||
params.push(maxPrice)
|
||||
}
|
||||
if (Number.isFinite(itemId)) {
|
||||
where.push('i.item_id = ?')
|
||||
params.push(itemId)
|
||||
}
|
||||
if (map) {
|
||||
where.push('v.map = ?')
|
||||
params.push(map)
|
||||
}
|
||||
if (region) {
|
||||
where.push('v.region = ?')
|
||||
params.push(region)
|
||||
}
|
||||
|
||||
return { sql: `WHERE ${where.join(' AND ')}`, params }
|
||||
}
|
||||
|
||||
// Whitelisted, because this interpolates into the statement. `recent` sorts by
|
||||
// the vendor's freshness, which is the only way to see what has just been listed
|
||||
// on a shard whose sweep is minutes wide.
|
||||
const SORTS = {
|
||||
price_asc: 'i.price ASC, i.id ASC',
|
||||
price_desc: 'i.price DESC, i.id ASC',
|
||||
recent: 'v.updated_at DESC, i.id ASC',
|
||||
}
|
||||
|
||||
async function searchListings({ q, minPrice, maxPrice, itemId, map, region, sort, limit, offset }) {
|
||||
const { sql, params } = searchWhere({ q, minPrice, maxPrice, itemId, map, region })
|
||||
const order = SORTS[sort] || SORTS.price_asc
|
||||
|
||||
const rows = await query(
|
||||
`SELECT i.serial, i.item_id, i.hue, i.amount, i.price, i.name, i.cliloc, i.display_name, i.child,
|
||||
v.serial AS vendor_serial, v.shop_name, v.owner_serial, v.owner_name,
|
||||
v.map, v.x, v.y, v.z, v.region, v.house, v.updated_at
|
||||
FROM shard_vendor_items i
|
||||
JOIN shard_vendors v ON v.serial = i.vendor_serial
|
||||
${sql}
|
||||
ORDER BY ${order}
|
||||
LIMIT ? OFFSET ?`,
|
||||
[...params, limit, offset],
|
||||
)
|
||||
|
||||
const counted = await query(
|
||||
`SELECT COUNT(*) AS n
|
||||
FROM shard_vendor_items i
|
||||
JOIN shard_vendors v ON v.serial = i.vendor_serial
|
||||
${sql}`,
|
||||
params,
|
||||
)
|
||||
|
||||
return { rows, total: Number(counted[0]?.n) || 0 }
|
||||
}
|
||||
|
||||
async function getVendor(serial) {
|
||||
const rows = await query(
|
||||
`SELECT serial, shop_name, owner_serial, owner_name, map, x, y, z, region, house,
|
||||
item_count, item_total, truncated, t, updated_at
|
||||
FROM shard_vendors WHERE serial = ?`,
|
||||
[serial],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
async function listVendorItems(serial, { limit, offset }) {
|
||||
return query(
|
||||
`SELECT serial, item_id, hue, amount, price, name, cliloc, display_name, child
|
||||
FROM shard_vendor_items
|
||||
WHERE vendor_serial = ?
|
||||
ORDER BY price ASC, id ASC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[serial, limit, offset],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What the market page's header needs: how big the index is, and how stale it may
|
||||
* be. `staleAt` is the OLDEST vendor row — the round-robin sweep means a shop can
|
||||
* be a full cycle behind, and the page says so rather than implying live prices.
|
||||
*/
|
||||
async function meta() {
|
||||
const rows = await query(
|
||||
`SELECT COUNT(*) AS vendors, MIN(updated_at) AS stale_at, MAX(updated_at) AS fresh_at
|
||||
FROM shard_vendors`,
|
||||
)
|
||||
const items = await query('SELECT COUNT(*) AS n FROM shard_vendor_items')
|
||||
return {
|
||||
vendors: Number(rows[0]?.vendors) || 0,
|
||||
items: Number(items[0]?.n) || 0,
|
||||
staleAt: rows[0]?.stale_at || null,
|
||||
freshAt: rows[0]?.fresh_at || null,
|
||||
}
|
||||
}
|
||||
|
||||
/** The distinct facets and regions holding vendors — drives the page's filters. */
|
||||
async function listPlaces() {
|
||||
const maps = await query(
|
||||
'SELECT DISTINCT map FROM shard_vendors WHERE map IS NOT NULL ORDER BY map',
|
||||
)
|
||||
const regions = await query(
|
||||
'SELECT DISTINCT region FROM shard_vendors WHERE region IS NOT NULL ORDER BY region',
|
||||
)
|
||||
return { maps: maps.map((r) => r.map), regions: regions.map((r) => r.region) }
|
||||
}
|
||||
|
||||
// ── Cliloc re-resolution ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* One page of listings whose name still needs resolving, for the bulk pass that
|
||||
* runs after a cliloc import.
|
||||
*
|
||||
* Keyed on `id > after` rather than OFFSET: the pass updates the very rows it is
|
||||
* scanning, and an OFFSET walk over a table being rewritten skips rows. Every
|
||||
* row with a cliloc is re-read, not just the unresolved ones, because an import
|
||||
* can also CHANGE a name — a shard overlay relabelling a stock item is the whole
|
||||
* reason overlays exist.
|
||||
*/
|
||||
async function listResolvableItems(after, limit) {
|
||||
return query(
|
||||
`SELECT id, cliloc, name, display_name
|
||||
FROM shard_vendor_items
|
||||
WHERE cliloc IS NOT NULL AND cliloc > 0 AND id > ?
|
||||
ORDER BY id
|
||||
LIMIT ?`,
|
||||
[after, limit],
|
||||
)
|
||||
}
|
||||
|
||||
/** Write back a batch of re-resolved display names. */
|
||||
async function updateDisplayNames(pairs) {
|
||||
if (pairs.length === 0) return 0
|
||||
const conn = await pool.getConnection()
|
||||
try {
|
||||
await conn.batch('UPDATE shard_vendor_items SET display_name = ? WHERE id = ?', pairs)
|
||||
return pairs.length
|
||||
} finally {
|
||||
conn.release()
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
replaceVendor,
|
||||
removeVendor,
|
||||
searchListings,
|
||||
getVendor,
|
||||
listVendorItems,
|
||||
meta,
|
||||
listPlaces,
|
||||
listResolvableItems,
|
||||
updateDisplayNames,
|
||||
likeTerm,
|
||||
}
|
||||
329
server/src/model/shardMarket/shardMarket.model.js
Normal file
329
server/src/model/shardMarket/shardMarket.model.js
Normal file
@@ -0,0 +1,329 @@
|
||||
// ── Player-vendor market index (Protocol 3.0 vendor.listing) ───────────────
|
||||
//
|
||||
// The shard-wide shop index: what every player vendor is selling, for how much,
|
||||
// and where it is standing. This is the website's half of the search the in-game
|
||||
// Vendor Search gump offers — the same data, the same opt-out, reachable without
|
||||
// logging in to the game.
|
||||
//
|
||||
// Ingest is per-vendor and authoritative: the shard's round-robin sweep emits one
|
||||
// `vendor.listing` frame per shop whose contents, prices or location moved, and
|
||||
// the frame is the whole shop (see docs/link/v3.md §8 and BridgeMarket.cs). This
|
||||
// module normalizes it into shard_vendors + shard_vendor_items and, crucially,
|
||||
// resolves each listing's cliloc to a DISPLAY NAME on the way in — a search for
|
||||
// "kryss" is a search over names, and the shard only ever sends numbers.
|
||||
|
||||
const db = require('./shardMarket.db')
|
||||
const clilocs = require('../shardClilocs/shardClilocs.model')
|
||||
const log = require('../../utils/logger')('shard-market')
|
||||
|
||||
// Defense in depth on top of the shard's own MarketMaxListings cap. The shard is
|
||||
// trusted, but it is a separately-versioned component: a frame from a plugin
|
||||
// whose cap was raised (or a shard running modified scripts) must not be able to
|
||||
// turn one ingest into an unbounded transaction.
|
||||
const MAX_ITEMS_PER_VENDOR = 5000
|
||||
|
||||
// Column widths in schema.sql. Truncating here rather than letting MariaDB do it
|
||||
// keeps the behavior the same in strict mode, where an over-length value is an
|
||||
// ERROR and would fail the whole vendor rather than shortening one name.
|
||||
const MAX_NAME = 160
|
||||
const MAX_SHOP = 160
|
||||
const MAX_OWNER = 64
|
||||
const MAX_MAP = 40
|
||||
const MAX_REGION = 80
|
||||
const MAX_SERIAL = 20
|
||||
|
||||
const clip = (value, max) => {
|
||||
if (value == null) return null
|
||||
const s = String(value)
|
||||
return s.length > max ? s.slice(0, max) : s
|
||||
}
|
||||
|
||||
const int = (value, fallback = 0) => {
|
||||
const n = Number(value)
|
||||
return Number.isFinite(n) ? Math.trunc(n) : fallback
|
||||
}
|
||||
|
||||
// ── Ingest ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Flatten one `vendor.listing` frame into the row shapes the DB layer wants.
|
||||
*
|
||||
* `location` arrives as a nested object rather than flat map/x/y/region, and that
|
||||
* shape is load-bearing rather than cosmetic: the visibility projection matches
|
||||
* literal JSON keys, so ONE `market.location` rule can hide a vendor's
|
||||
* whereabouts only if `location` is a single key on both the live frame and the
|
||||
* stored read model. Flattening it here for storage and re-nesting it on read is
|
||||
* what keeps that true on both paths.
|
||||
*
|
||||
* Exported for tests — it is the part with rules in it, and it is pure.
|
||||
*/
|
||||
function flattenFrame(ev) {
|
||||
const loc = (ev && ev.location) || {}
|
||||
return {
|
||||
serial: clip(ev.serial, MAX_SERIAL),
|
||||
shopName: clip(ev.shopName, MAX_SHOP),
|
||||
ownerSerial: clip(ev.ownerSerial, MAX_SERIAL),
|
||||
ownerName: clip(ev.ownerName, MAX_OWNER),
|
||||
map: clip(loc.map, MAX_MAP),
|
||||
x: Number.isFinite(loc.x) ? Math.trunc(loc.x) : null,
|
||||
y: Number.isFinite(loc.y) ? Math.trunc(loc.y) : null,
|
||||
z: Number.isFinite(loc.z) ? Math.trunc(loc.z) : null,
|
||||
region: clip(loc.region, MAX_REGION),
|
||||
house: clip(loc.house, MAX_SHOP),
|
||||
// What the SHOP holds, which is not what the frame carries when it was
|
||||
// truncated. Kept apart so the page can say "showing 250 of 3,104" rather
|
||||
// than presenting a partial shop as a complete one.
|
||||
itemTotal: int(ev.total, int(ev.count, 0)),
|
||||
truncated: ev.truncated === true,
|
||||
t: Number.isFinite(ev.t) ? ev.t : null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve each listing's display name.
|
||||
*
|
||||
* Order of preference is the item's own literal `name` first, then the cliloc.
|
||||
* That is the opposite of what "resolve the id" suggests and it is right: a
|
||||
* literal name only exists because a player set one ("Bob's vanquishing kryss"),
|
||||
* and it is strictly more specific than the generic cliloc the item still
|
||||
* carries.
|
||||
*
|
||||
* One batched lookup per frame rather than per item; `resolveMany` is cached and
|
||||
* never throws, so a cliloc table that is missing entirely just leaves
|
||||
* `displayName` null and the page renders item ids, exactly as it did before the
|
||||
* table existed.
|
||||
*/
|
||||
async function shapeItems(ev) {
|
||||
const raw = Array.isArray(ev.items) ? ev.items.slice(0, MAX_ITEMS_PER_VENDOR) : []
|
||||
|
||||
const wanted = raw
|
||||
.map((i) => int(i && i.cliloc, 0))
|
||||
.filter((n) => n > 0)
|
||||
|
||||
const names = await clilocs.resolveMany(wanted)
|
||||
|
||||
return raw
|
||||
.filter((i) => i && i.serial)
|
||||
.map((i) => {
|
||||
const literal = clip(i.name, MAX_NAME)
|
||||
const cliloc = int(i.cliloc, 0) || null
|
||||
return {
|
||||
serial: clip(i.serial, MAX_SERIAL),
|
||||
itemId: int(i.itemId, 0),
|
||||
hue: int(i.hue, 0),
|
||||
amount: int(i.amount, 1),
|
||||
price: int(i.price, 0),
|
||||
name: literal,
|
||||
cliloc,
|
||||
displayName: literal || (cliloc ? clip(names.get(cliloc) ?? null, MAX_NAME) : null),
|
||||
child: i.child === true,
|
||||
}
|
||||
})
|
||||
// Unpriced rows are inventory, not listings. The shard already drops them;
|
||||
// this is the same rule enforced where the table is written, so a plugin that
|
||||
// stops enforcing it cannot put un-buyable rows on the market page.
|
||||
.filter((i) => i.price > 0)
|
||||
}
|
||||
|
||||
/** Ingest one `vendor.listing` frame. */
|
||||
async function upsertVendor(ev) {
|
||||
if (!ev || !ev.serial) return
|
||||
const vendor = flattenFrame(ev)
|
||||
const items = await shapeItems(ev)
|
||||
await db.replaceVendor(vendor, items)
|
||||
}
|
||||
|
||||
/** Ingest one `vendor.listing.remove` frame. */
|
||||
async function removeVendor(serial) {
|
||||
if (!serial) return
|
||||
await db.removeVendor(String(serial).slice(0, MAX_SERIAL))
|
||||
}
|
||||
|
||||
// ── Read models ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// `location` is re-nested (see flattenFrame) so the stored read model and the
|
||||
// live wire frame present the same keys to the visibility projection.
|
||||
|
||||
const place = (r) => ({
|
||||
map: r.map,
|
||||
x: r.x,
|
||||
y: r.y,
|
||||
z: r.z,
|
||||
region: r.region,
|
||||
house: r.house,
|
||||
})
|
||||
|
||||
// A listing as the search returns it: the item, plus enough of its shop to be
|
||||
// actionable without a second request. `displayName` falls back to nothing rather
|
||||
// than to a fabricated "Item 3922" — the client decides how to render an
|
||||
// unresolved id, and inventing a name here would make it indistinguishable from
|
||||
// a real one.
|
||||
const shapeListing = (r) => ({
|
||||
serial: r.serial,
|
||||
itemId: r.item_id,
|
||||
hue: r.hue,
|
||||
amount: r.amount,
|
||||
price: Number(r.price),
|
||||
name: r.name,
|
||||
cliloc: r.cliloc,
|
||||
displayName: r.display_name,
|
||||
child: !!r.child,
|
||||
vendor: {
|
||||
serial: r.vendor_serial,
|
||||
shopName: r.shop_name,
|
||||
ownerSerial: r.owner_serial,
|
||||
ownerName: r.owner_name,
|
||||
location: place(r),
|
||||
updatedAt: r.updated_at,
|
||||
},
|
||||
})
|
||||
|
||||
const shapeVendor = (r) => ({
|
||||
serial: r.serial,
|
||||
shopName: r.shop_name,
|
||||
ownerSerial: r.owner_serial,
|
||||
ownerName: r.owner_name,
|
||||
location: place(r),
|
||||
count: r.item_count,
|
||||
total: r.item_total,
|
||||
truncated: !!r.truncated,
|
||||
updatedAt: r.updated_at,
|
||||
})
|
||||
|
||||
const shapeItem = (r) => ({
|
||||
serial: r.serial,
|
||||
itemId: r.item_id,
|
||||
hue: r.hue,
|
||||
amount: r.amount,
|
||||
price: Number(r.price),
|
||||
name: r.name,
|
||||
cliloc: r.cliloc,
|
||||
displayName: r.display_name,
|
||||
child: !!r.child,
|
||||
})
|
||||
|
||||
/**
|
||||
* Search the index. Returns a page of LISTINGS (not vendors) plus the
|
||||
* unpaginated total and the staleness stamp the page's banner needs.
|
||||
*/
|
||||
async function search({
|
||||
q = '',
|
||||
minPrice,
|
||||
maxPrice,
|
||||
itemId,
|
||||
map = '',
|
||||
region = '',
|
||||
sort = 'price_asc',
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
} = {}) {
|
||||
const { rows, total } = await db.searchListings({
|
||||
q: q.trim(),
|
||||
minPrice: Number.isFinite(minPrice) ? minPrice : undefined,
|
||||
maxPrice: Number.isFinite(maxPrice) ? maxPrice : undefined,
|
||||
itemId: Number.isFinite(itemId) ? itemId : undefined,
|
||||
map: map.trim(),
|
||||
region: region.trim(),
|
||||
sort,
|
||||
limit,
|
||||
offset,
|
||||
})
|
||||
|
||||
const info = await db.meta()
|
||||
|
||||
return {
|
||||
listings: rows.map(shapeListing),
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
// Repeated on every search response rather than left to a separate /meta
|
||||
// call: the banner that says how old these prices are must age with the
|
||||
// results it labels, and a client that fetched it once would keep showing a
|
||||
// stamp from before the page it is looking at.
|
||||
staleAt: info.staleAt,
|
||||
vendors: info.vendors,
|
||||
}
|
||||
}
|
||||
|
||||
/** One shop and its listings. `null` when the index has never seen that serial. */
|
||||
async function getVendor(serial, { limit = 250, offset = 0 } = {}) {
|
||||
const row = await db.getVendor(serial)
|
||||
if (!row) return null
|
||||
const items = await db.listVendorItems(serial, { limit, offset })
|
||||
return { ...shapeVendor(row), items: items.map(shapeItem) }
|
||||
}
|
||||
|
||||
/** Index size, staleness, and the facet/region filter options. */
|
||||
async function meta() {
|
||||
const [info, places] = await Promise.all([db.meta(), db.listPlaces()])
|
||||
return { ...info, ...places }
|
||||
}
|
||||
|
||||
// ── Cliloc re-resolution ───────────────────────────────────────────────────
|
||||
|
||||
// Batch size for the post-import pass. Big enough that a 40k-row table is ~40
|
||||
// round trips, small enough that a single batch is not a long-held connection.
|
||||
const RESOLVE_BATCH = 1000
|
||||
|
||||
/**
|
||||
* Re-resolve every listing's display name against the current cliloc table.
|
||||
*
|
||||
* Called after a cliloc import, and it has to be: the market's diff sweep will
|
||||
* NOT re-send an unchanged shop just because the site learned what its items are
|
||||
* called, so without this an operator who configures clilocs after the first
|
||||
* market sweep sees item ids until every shop happens to change. That is the same
|
||||
* class of staleness the spawn atlas avoids by re-parsing on boot — here the
|
||||
* source of truth for names moved, not the data.
|
||||
*
|
||||
* Never throws. It is a cosmetic backfill on a table that is already serving; a
|
||||
* failure means names stay as they were, which is exactly the pre-import state.
|
||||
*/
|
||||
async function refreshDisplayNames() {
|
||||
let after = 0
|
||||
let scanned = 0
|
||||
let changed = 0
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
const rows = await db.listResolvableItems(after, RESOLVE_BATCH)
|
||||
if (rows.length === 0) break
|
||||
|
||||
after = rows[rows.length - 1].id
|
||||
scanned += rows.length
|
||||
|
||||
const names = await clilocs.resolveMany(rows.map((r) => Number(r.cliloc)))
|
||||
|
||||
const pairs = []
|
||||
for (const row of rows) {
|
||||
// The literal name still wins, so a re-resolution never overwrites a
|
||||
// player-set name with the generic cliloc behind it.
|
||||
const next = row.name
|
||||
? clip(row.name, MAX_NAME)
|
||||
: clip(names.get(Number(row.cliloc)) ?? null, MAX_NAME)
|
||||
if (next !== row.display_name) pairs.push([next, row.id])
|
||||
}
|
||||
|
||||
changed += await db.updateDisplayNames(pairs)
|
||||
}
|
||||
|
||||
if (changed > 0) log.info('market display names refreshed', { scanned, changed })
|
||||
return { scanned, changed }
|
||||
} catch (err) {
|
||||
log.warn('market display-name refresh failed', { message: err.message, scanned, changed })
|
||||
return { scanned, changed, error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
upsertVendor,
|
||||
removeVendor,
|
||||
search,
|
||||
getVendor,
|
||||
meta,
|
||||
refreshDisplayNames,
|
||||
flattenFrame,
|
||||
shapeItems,
|
||||
shapeListing,
|
||||
shapeVendor,
|
||||
MAX_ITEMS_PER_VENDOR,
|
||||
}
|
||||
@@ -278,6 +278,50 @@ async function getRuleset() {
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
// ── Points/loyalty boards (Protocol 3.0 points.board) ──────────────────────
|
||||
// One row per point system. The shard only emits a system whose top N actually
|
||||
// moved, so this is a sparse stream of overwrites; there is no delete, because
|
||||
// the shard's set of systems is fixed at startup.
|
||||
async function upsertPointsBoard({ system, name, nameCliloc, maxPoints, players, showOnGump, payload, t }) {
|
||||
await query(
|
||||
`INSERT INTO shard_points_boards
|
||||
(system, name, name_cliloc, max_points, players, show_on_gump, payload, t)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE name = VALUES(name), name_cliloc = VALUES(name_cliloc),
|
||||
max_points = VALUES(max_points), players = VALUES(players),
|
||||
show_on_gump = VALUES(show_on_gump), payload = VALUES(payload), t = VALUES(t)`,
|
||||
[
|
||||
system,
|
||||
name ?? null,
|
||||
Number.isFinite(nameCliloc) ? nameCliloc : null,
|
||||
Number.isFinite(maxPoints) ? maxPoints : null,
|
||||
Number.isFinite(players) ? players : null,
|
||||
showOnGump ? 1 : 0,
|
||||
payload,
|
||||
Number.isFinite(t) ? t : null,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
// Ordered by display name, falling back to the system key for a board whose name
|
||||
// arrived as a bare cliloc — otherwise every unresolved board would sort together
|
||||
// under NULL.
|
||||
async function listPointsBoards() {
|
||||
return query(
|
||||
`SELECT system, name, name_cliloc, max_points, players, show_on_gump, payload, t, updated_at
|
||||
FROM shard_points_boards ORDER BY COALESCE(name, system), system`,
|
||||
)
|
||||
}
|
||||
|
||||
async function getPointsBoard(system) {
|
||||
const rows = await query(
|
||||
`SELECT system, name, name_cliloc, max_points, players, show_on_gump, payload, t, updated_at
|
||||
FROM shard_points_boards WHERE system = ?`,
|
||||
[system],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
upsertOnline,
|
||||
removeOnline,
|
||||
@@ -311,6 +355,9 @@ module.exports = {
|
||||
latestPresence,
|
||||
setRuleset,
|
||||
getRuleset,
|
||||
upsertPointsBoard,
|
||||
listPointsBoards,
|
||||
getPointsBoard,
|
||||
upsertChamp,
|
||||
removeChamp,
|
||||
clearChamps,
|
||||
|
||||
@@ -537,6 +537,50 @@ async function getRuleset() {
|
||||
return { ...payload, updatedAt: r.updated_at }
|
||||
}
|
||||
|
||||
// ── Points/loyalty boards (Protocol 3.0 points.board) ──────────────────────
|
||||
//
|
||||
// The whole frame is stored in `payload`; the columns beside it are hoisted for
|
||||
// listing and ordering only. The top-N list deliberately stays inside the payload
|
||||
// (see schema.sql) — it is a fixed-size list read whole, like the governor board's
|
||||
// candidates.
|
||||
async function upsertPointsBoard(ev) {
|
||||
if (!ev || !ev.system) return
|
||||
await db.upsertPointsBoard({
|
||||
system: String(ev.system).slice(0, 48),
|
||||
name: ev.nameString ?? null,
|
||||
nameCliloc: ev.nameNumber,
|
||||
maxPoints: ev.maxPoints,
|
||||
players: ev.players,
|
||||
showOnGump: ev.showOnGump !== false,
|
||||
payload: JSON.stringify(ev),
|
||||
t: ev.t,
|
||||
})
|
||||
}
|
||||
|
||||
// A stored frame plus the freshness stamp. `top` is normalized to an array so a
|
||||
// caller never has to guard it — a board with nobody on it is a real state (a
|
||||
// system nobody has scored in yet), distinct from a system that was never
|
||||
// published at all, which is absent from the table entirely.
|
||||
function shapePointsBoard(r) {
|
||||
const payload = (typeof r.payload === 'string' ? safeJson(r.payload) : r.payload) || {}
|
||||
return {
|
||||
...payload,
|
||||
system: r.system,
|
||||
top: Array.isArray(payload.top) ? payload.top : [],
|
||||
updatedAt: r.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
async function listPointsBoards() {
|
||||
const rows = await db.listPointsBoards()
|
||||
return rows.map(shapePointsBoard)
|
||||
}
|
||||
|
||||
async function getPointsBoard(system) {
|
||||
const r = await db.getPointsBoard(system)
|
||||
return r ? shapePointsBoard(r) : null
|
||||
}
|
||||
|
||||
function safeJson(s) {
|
||||
try {
|
||||
return JSON.parse(s)
|
||||
@@ -588,4 +632,7 @@ module.exports = {
|
||||
latestPresence,
|
||||
setRuleset,
|
||||
getRuleset,
|
||||
upsertPointsBoard,
|
||||
listPointsBoards,
|
||||
getPointsBoard,
|
||||
}
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
const db = require('./uoLinkConfig.db')
|
||||
const secretBox = require('../../utils/secretBox')
|
||||
|
||||
const DEFAULT_PROTOCOL = Number(process.env.UOLINK_PROTOCOL) || 1
|
||||
// The wire protocol this build speaks (link/sidecar/src/main.rs PROTOCOL_VERSION).
|
||||
// Only used before an admin has saved anything — the stored row wins once it exists,
|
||||
// and UOLINK_PROTOCOL still overrides for an operator running an older sidecar.
|
||||
const DEFAULT_PROTOCOL = Number(process.env.UOLINK_PROTOCOL) || 3
|
||||
|
||||
function toSafe(row) {
|
||||
if (!row) {
|
||||
|
||||
208
server/src/modules/lifecycle.js
Normal file
208
server/src/modules/lifecycle.js
Normal file
@@ -0,0 +1,208 @@
|
||||
// ── Module lifecycle dispatch ──────────────────────────────────────────────
|
||||
//
|
||||
// Phase 2, PR 5 of docs/website/MODULE_SYSTEM.md §2.7. Normative contract:
|
||||
// docs/website/MODULE_API.md §2.5 (the hooks and when they run) and §4.4
|
||||
// (failure is a state), plus MODULE_SYSTEM.md §2.4 (what a boot does to
|
||||
// `installed_modules`).
|
||||
//
|
||||
// This is the database half of the loader, and it is a separate file for the
|
||||
// same reason modules/schema.js is: `scripts/routeManifest.js` and
|
||||
// `swagger/swagger.js` both require app.js with the pool pointed at a dead port,
|
||||
// so loader.js may not reach the database. Everything here runs from server.js,
|
||||
// after ensureSchema() has proved the database is up.
|
||||
//
|
||||
// The two halves meet at exactly one place — `loader.setState()` — so the
|
||||
// in-memory record that the §4.5 dispatch guard reads and the row the admin
|
||||
// panel reads are moved together and cannot disagree.
|
||||
//
|
||||
// Two properties this file exists to keep:
|
||||
//
|
||||
// 1. **A module's boot failure costs that module and nothing else** (§4.4).
|
||||
// Its routes stay mounted and answer 503; the site comes up; the next
|
||||
// module boots as if nothing happened.
|
||||
// 2. **The row is a record of what happened, never the source of truth for
|
||||
// what is mounted** (§2.4). Nothing here mounts, unmounts or re-scans. It
|
||||
// reads the outcome of a scan that already happened and writes it down.
|
||||
|
||||
const log = require('../utils/logger')('modules')
|
||||
|
||||
// §2.5: shutdown races the process being killed, so a module that will not let
|
||||
// go is logged and skipped rather than allowed to hang the exit. `onBoot` has no
|
||||
// such budget on purpose — it delays the listener binding, which is the feature.
|
||||
const SHUTDOWN_BUDGET_MS = 5000
|
||||
|
||||
/**
|
||||
* Run one database call for one module without letting it become everyone's
|
||||
* failure. Returns null on failure, having logged it.
|
||||
*
|
||||
* The boot path is the whole reason this exists. A row that will not update is
|
||||
* bad — the admin panel shows the wrong thing — but it is strictly less bad than
|
||||
* a site that will not start, and it must not stop the modules after it from
|
||||
* booting.
|
||||
*/
|
||||
async function safe(what, fn) {
|
||||
try {
|
||||
return await fn()
|
||||
} catch (err) {
|
||||
log.error(`module bookkeeping failed: ${what}`, { error: err.message })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile `installed_modules` with what the loader found, then run every
|
||||
* surviving module's `onBoot`.
|
||||
*
|
||||
* Called once from server.js, after `ensureSchema()` (so a module's own tables
|
||||
* exist) and `seedDefaults()`, and **before the HTTP listener binds** — a module
|
||||
* that must not serve traffic until it has warmed a cache gets that for free
|
||||
* (§2.5).
|
||||
*
|
||||
* The order of the four steps is the whole design:
|
||||
*
|
||||
* 1. `beginBoot()` clears the last boot's outcomes, so what is on display
|
||||
* afterwards is what THIS boot did. `disabled` rows are left alone: that is
|
||||
* an operator decision, not an outcome (§2.4).
|
||||
* 2. Every module on the volume gets a row, written with null provenance if it
|
||||
* does not have one — a directory placed on the volume by hand is a
|
||||
* supported install (§2.4/§2.5), and without a row it could never be
|
||||
* disabled or shown as failed.
|
||||
* 3. Rows with no directory are marked failed, because step 1 has just reset
|
||||
* them to `enabled` and a row claiming to be enabled for a module that is
|
||||
* not there is the one state that is simply untrue. (A plain uninstall
|
||||
* leaves `disabled`, which step 1 never touches, so this only catches a
|
||||
* directory deleted by hand.)
|
||||
* 4. The outcome each module already carries — disabled by the operator,
|
||||
* failed during load or schema replay, or ready — is written down, and only
|
||||
* then is `onBoot` dispatched.
|
||||
*
|
||||
* Never throws. @param {object} [deps] injection seam for tests.
|
||||
*/
|
||||
async function boot({ modules, model } = {}) {
|
||||
/* eslint-disable global-require */
|
||||
const loader = modules || require('./loader')
|
||||
const rows = model || require('../model/modules/modules.model')
|
||||
/* eslint-enable global-require */
|
||||
|
||||
// Not an error, and the same guard replayFragments carries: a process that
|
||||
// never required app.js has no scan to reconcile against, and writing rows
|
||||
// from an empty list would mark every installed module as missing.
|
||||
if (!loader.isLoaded()) {
|
||||
log.info('no module scan in this process — skipping module boot')
|
||||
return
|
||||
}
|
||||
|
||||
const scanned = loader.list()
|
||||
|
||||
await safe('resetting last boot\'s outcomes', () => rows.beginBoot())
|
||||
|
||||
for (const m of scanned) {
|
||||
await safe(`recording module "${m.id}"`, () => rows.recordInstalled({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
version: m.version,
|
||||
// Null provenance is what a hand-placed directory looks like. An install
|
||||
// performed through the admin panel (§2.5, a later phase) writes the row
|
||||
// with its source and hash first; this refresh deliberately does not
|
||||
// overwrite either, because recordInstalled leaves what it is not given.
|
||||
}))
|
||||
}
|
||||
|
||||
const stored = (await safe('reading module rows', () => rows.list())) || []
|
||||
const onVolume = new Set(scanned.map((m) => m.id))
|
||||
|
||||
for (const row of stored) {
|
||||
if (onVolume.has(row.id) || row.state === 'disabled') continue
|
||||
await safe(`marking module "${row.id}" missing`, () => rows.markStartupFailed(row.id, {
|
||||
stage: 'require',
|
||||
reason: 'module directory not present on the volume',
|
||||
}))
|
||||
}
|
||||
|
||||
const disabled = new Set(stored.filter((r) => r.state === 'disabled').map((r) => r.id))
|
||||
|
||||
for (const m of scanned) {
|
||||
// The operator's switch wins over everything, including a failure. Its
|
||||
// routes 404 from here on (§4.5 — the leg that was unreachable until this
|
||||
// PR), it is not booted, and its failure is not re-recorded: overwriting a
|
||||
// deliberate `disabled` with an outcome would silently re-enable it on the
|
||||
// next boot.
|
||||
if (disabled.has(m.id)) {
|
||||
loader.setState(m.id, 'disabled')
|
||||
continue
|
||||
}
|
||||
if (m.state === 'startup_failed') {
|
||||
// Already failed in load() or the schema replay — both of which ran before
|
||||
// the database was available to write it down. This is where it lands.
|
||||
await safe(`recording failure for "${m.id}"`, () => rows.markStartupFailed(m.id, {
|
||||
stage: m.stage,
|
||||
reason: m.reason,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
for (const { id, hook, ctx } of loader.bootable()) {
|
||||
try {
|
||||
// Awaited without a timeout, deliberately (§2.5): a slow onBoot delays the
|
||||
// listener, which is the contract's promise to a module that must warm up
|
||||
// before it serves. Core's own boot steps are awaited the same way.
|
||||
if (hook) await hook(ctx)
|
||||
loader.setState(id, 'started')
|
||||
await safe(`marking module "${id}" started`, () => rows.markStarted(id))
|
||||
log.info(`module "${id}" started`)
|
||||
} catch (err) {
|
||||
// §4.4's second column: the routes are already mounted, so they stay
|
||||
// mounted and answer 503. A module that failed to warm up serving
|
||||
// half-initialised data is worse than one that says it is down.
|
||||
loader.setState(id, 'startup_failed', { stage: 'boot', reason: err.message })
|
||||
await safe(`recording boot failure for "${id}"`, () => rows.markStartupFailed(id, {
|
||||
stage: 'boot',
|
||||
reason: err.message,
|
||||
}))
|
||||
log.error(`module "${id}" onBoot failed — its routes will answer 503`, {
|
||||
reason: err.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Reject if `fn`'s promise has not settled within `ms`. */
|
||||
function withBudget(fn, ms) {
|
||||
let timer
|
||||
const budget = new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(`onShutdown exceeded its ${ms}ms budget`)), ms)
|
||||
})
|
||||
return Promise.race([Promise.resolve().then(fn), budget]).finally(() => clearTimeout(timer))
|
||||
}
|
||||
|
||||
/**
|
||||
* Run every started module's `onShutdown`, in reverse registration order.
|
||||
*
|
||||
* Called from server.js's signal handler before anything core owns is closed, so
|
||||
* a module still has a working database pool and push dispatcher to flush
|
||||
* through. Reverse order is the mirror of boot order: a module that booted after
|
||||
* another may be holding something the earlier one handed it.
|
||||
*
|
||||
* Never throws, and never hangs: each hook gets `SHUTDOWN_BUDGET_MS`, after
|
||||
* which it is logged and abandoned. Abandoned, not cancelled — nothing can stop
|
||||
* a promise that is still running — but the process is exiting anyway, and the
|
||||
* alternative is a shard host where `systemctl stop` hangs until SIGKILL.
|
||||
*/
|
||||
async function shutdown({ modules, budgetMs = SHUTDOWN_BUDGET_MS } = {}) {
|
||||
// eslint-disable-next-line global-require
|
||||
const loader = modules || require('./loader')
|
||||
if (!loader.isLoaded()) return
|
||||
|
||||
for (const { id, hook } of loader.shutdownHooks()) {
|
||||
try {
|
||||
await withBudget(hook, budgetMs)
|
||||
log.info(`module "${id}" shut down`)
|
||||
} catch (err) {
|
||||
log.warn(`module "${id}" onShutdown failed or timed out — continuing`, {
|
||||
error: err.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { boot, shutdown, SHUTDOWN_BUDGET_MS }
|
||||
797
server/src/modules/loader.js
Normal file
797
server/src/modules/loader.js
Normal file
@@ -0,0 +1,797 @@
|
||||
// ── The module loader ──────────────────────────────────────────────────────
|
||||
//
|
||||
// Phase 2, PR 2 of docs/website/MODULE_SYSTEM.md §2.7. The normative contract is
|
||||
// docs/website/MODULE_API.md Part 4; where the two disagree, the contract wins.
|
||||
//
|
||||
// The one property this file exists to guarantee, and the reason it looks the
|
||||
// way it does:
|
||||
//
|
||||
// **The filesystem is the mounting source of truth, and mounting is
|
||||
// SYNCHRONOUS.** `scripts/routeManifest.js:38` and `swagger/swagger.js:29`
|
||||
// both require app.js with the pool pointed at a dead port. A loader that
|
||||
// awaited a database row before mounting would make every module route
|
||||
// invisible to the frozen-URL-surface test (§1.12). So: readdirSync at require
|
||||
// time, no database, no promises (§4.1).
|
||||
//
|
||||
// A module that fails ANYWHERE in this file fails alone. Nothing here may throw
|
||||
// past its own try/catch — a bad module must cost the site its routes, never its
|
||||
// boot (§4.4).
|
||||
//
|
||||
// PR 7 added the client half's server-side end: validating `client.entry` and
|
||||
// publishing where the chunk lives (`clientChunks()`), so app.js can serve it and
|
||||
// utils/htmlShell.js can inject its script tag. The loader resolves and validates;
|
||||
// it does not mount, because the chunk hangs off the ROOT app rather than a tier
|
||||
// router, and app.js is where core's own static mounts live.
|
||||
//
|
||||
// PR 3 added the fragment half of the schema story: this file VALIDATES a
|
||||
// fragment (statement by statement, at load time, before anything is mounted)
|
||||
// and publishes it through `fragments()`. Replaying it needs a database, so it
|
||||
// belongs to modules/schema.js, which utils/db.js calls after core's schema.
|
||||
//
|
||||
// PR 5 added the lifecycle hooks a module registers here (`onBoot`/`onShutdown`)
|
||||
// and the failure STAGE carried beside every reason. Dispatching those hooks and
|
||||
// reconciling `installed_modules` need a database, so they live in
|
||||
// modules/lifecycle.js for the same reason schema.js is a separate file: this one
|
||||
// stays require-able against a dead pool.
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const { MODULE_API_VERSION } = require('./version')
|
||||
const semver = require('./semver')
|
||||
const registries = require('./registries')
|
||||
const { splitStatements } = require('../utils/sqlStatements')
|
||||
|
||||
const log = require('../utils/logger')('modules')
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..', '..', '..')
|
||||
const MODULES_DIR = process.env.MODULES_DIR || path.join(REPO_ROOT, 'modules')
|
||||
|
||||
// One segment, lowercase, no parameters. A module prefix that could contain a
|
||||
// `/` or a `:` would let a module reach outside the slot it was given.
|
||||
const ID = /^[a-z][a-z0-9-]{1,31}$/
|
||||
const PREFIX = /^\/[a-z0-9][a-z0-9-]*$/
|
||||
const TIERS = ['public', 'admin', 'player']
|
||||
|
||||
const MANIFEST_KEYS = new Set([
|
||||
'id', 'name', 'version', 'coreApi', 'server', 'client',
|
||||
'schema', 'purge', 'mounts', 'extensions', 'capabilities',
|
||||
])
|
||||
|
||||
// Extension slots are declared by core, at require time, in the router that owns
|
||||
// the resource (registries.declareSlot). The loader asks the registry which exist
|
||||
// rather than keeping a list, for the same reason the prefix check probes the
|
||||
// live tier routers: a second copy of the answer is a copy that drifts.
|
||||
|
||||
// id → record. Populated by load(), read by list().
|
||||
const modules = new Map()
|
||||
let loaded = false
|
||||
|
||||
// ── ctx ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Everything a module may reach in core, and nothing else (§2.3). Required
|
||||
// lazily inside the factory rather than at file scope: this file is required by
|
||||
// app.js, and hoisting these to the top would make the DB pool, the settings
|
||||
// model and the upload directory startup-time dependencies of the loader itself.
|
||||
function buildCtx(id, moduleRoot) {
|
||||
/* eslint-disable global-require */
|
||||
// The shared SERVER dependencies — the exact counterpart of window.__rg's
|
||||
// react/react-dom/react-router on the client, and load-bearing for the same
|
||||
// two reasons (§7.2).
|
||||
//
|
||||
// 1. A module lives at <repo>/modules/<id>/, OUTSIDE server/, so Node's
|
||||
// resolver walks up from there and never sees server/node_modules. A
|
||||
// module that required 'express' itself would fail to load — which is
|
||||
// exactly how this was discovered.
|
||||
// 2. Even if it resolved, a second copy of express in the process is a
|
||||
// second Router prototype and a second set of instanceof checks. One
|
||||
// express, owned by core, is the same rule as one React.
|
||||
//
|
||||
// The consequence for a module author is the same on both sides: declare these
|
||||
// external, never bundle them, take them from what core hands you.
|
||||
const express = require('express')
|
||||
const validator = require('express-validator')
|
||||
const db = require('../utils/db')
|
||||
const settings = require('../model/settings/settings.model')
|
||||
const posts = require('../model/posts/posts.model')
|
||||
const auth = require('../utils/auth')
|
||||
const pushDispatch = require('../utils/pushDispatch')
|
||||
const secretBox = require('../utils/secretBox')
|
||||
const createLogger = require('../utils/logger')
|
||||
const { requireAuth, requireRole } = require('../auth/session.middleware')
|
||||
const siteMode = require('../middleware/siteMode')
|
||||
const validate = require('../middleware/validate')
|
||||
const noindex = require('../middleware/noindex')
|
||||
const uploads = require('../router/v1/admin/imageUpload')
|
||||
/* eslint-enable global-require */
|
||||
|
||||
// Narrowed on purpose (§2.3): utils/auth also re-exports signToken,
|
||||
// setAuthCookie and the TOTP challenge primitives, and minting a session is
|
||||
// core's job. A module that needs an identity needs to READ one.
|
||||
const ctx = {
|
||||
moduleId: id,
|
||||
paths: { moduleRoot },
|
||||
express,
|
||||
validator,
|
||||
db: { query: db.query, pool: db.pool },
|
||||
log: (namespace) => createLogger(namespace ? `${id}:${namespace}` : id),
|
||||
settings: {
|
||||
get: settings.get,
|
||||
set: settings.set,
|
||||
getInstanceName: settings.getInstanceName,
|
||||
},
|
||||
auth: { getUserFromRequest: auth.getUserFromRequest },
|
||||
push: { publish: pushDispatch.publish },
|
||||
secretBox: { encrypt: secretBox.encrypt, decrypt: secretBox.decrypt },
|
||||
middleware: { requireAuth, requireRole, siteMode, validate, noindex },
|
||||
uploads,
|
||||
posts: {
|
||||
listAll: posts.listAll,
|
||||
getById: posts.getById,
|
||||
linkAnnounceJob: posts.linkAnnounceJob,
|
||||
markAnnounced: posts.markAnnounced,
|
||||
},
|
||||
}
|
||||
// A guard against accident, not against a hostile module — the boundary is
|
||||
// organisational, not a security boundary (MODULE_SYSTEM.md §2.2).
|
||||
for (const value of Object.values(ctx)) {
|
||||
if (value && typeof value === 'object') Object.freeze(value)
|
||||
}
|
||||
return Object.freeze(ctx)
|
||||
}
|
||||
|
||||
// ── The registration api ───────────────────────────────────────────────────
|
||||
|
||||
// Collects what the module registers so validation can compare it against what
|
||||
// module.json DECLARED. Declaration is the contract; a module that registers a
|
||||
// prefix it did not declare is rejected, because module.json is what the admin
|
||||
// panel, the collision check and the reviewer all read.
|
||||
function buildApi(record) {
|
||||
const once = (name) => {
|
||||
if (record.called.has(name)) throw new Error(`${name}() called twice`)
|
||||
record.called.add(name)
|
||||
}
|
||||
const hook = (name) => (fn) => {
|
||||
once(name)
|
||||
if (typeof fn !== 'function') throw new Error(`${name}: expected a function`)
|
||||
record.hooks[name] = fn
|
||||
}
|
||||
return {
|
||||
registerRoutes(mounts) {
|
||||
once('registerRoutes')
|
||||
if (!mounts || typeof mounts !== 'object') throw new Error('registerRoutes: expected an object')
|
||||
for (const [tier, byPrefix] of Object.entries(mounts)) {
|
||||
if (!TIERS.includes(tier)) throw new Error(`registerRoutes: unknown tier "${tier}"`)
|
||||
for (const [prefix, router] of Object.entries(byPrefix)) {
|
||||
if (!PREFIX.test(prefix)) throw new Error(`registerRoutes: bad prefix "${prefix}"`)
|
||||
if (typeof router !== 'function') throw new Error(`registerRoutes: ${tier}${prefix} is not a router`)
|
||||
record.routes[tier].set(prefix, router)
|
||||
}
|
||||
}
|
||||
},
|
||||
// The three de-entanglement registries (§2.4). They live in registries.js
|
||||
// rather than here because core registers through the same staging area, and
|
||||
// core has no `api` object.
|
||||
//
|
||||
// These STAGE. Nothing a module registers is visible to core until the
|
||||
// second pass commits it, for the reason the second pass exists at all: a
|
||||
// module that throws halfway through register(), or fails checkDeclared
|
||||
// after it, must leave nothing behind. A half-registered stream catalog
|
||||
// would be worse than a missing one — it would be a subscribable stream
|
||||
// nothing will ever publish to.
|
||||
registerExtension: record.staged.registerExtension,
|
||||
registerNotificationStreams(streams) {
|
||||
once('registerNotificationStreams')
|
||||
record.staged.registerNotificationStreams(streams)
|
||||
},
|
||||
registerAnnounceLeg: record.staged.registerAnnounceLeg,
|
||||
// The two lifecycle hooks (§2.5). Registered here, dispatched from
|
||||
// lifecycle.js — this file runs with no database and the hooks run with one.
|
||||
// Both are optional: a module with no warm-up and nothing to close simply
|
||||
// never calls them.
|
||||
onBoot: hook('onBoot'),
|
||||
onShutdown: hook('onShutdown'),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Validation ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Throw with the §4.3 step that failed attached.
|
||||
*
|
||||
* `installed_modules.failure_stage` exists so the admin panel can say *where* a
|
||||
* module broke and not only what the message was, and the model enumerates the
|
||||
* eight stages (`FAILURE_STAGES`). The steps that share one function — a
|
||||
* manifest read that also checks `coreApi`, the mounts and the slots — cannot be
|
||||
* told apart by position in load(), so they carry their own label; everything
|
||||
* else is inferred from how far load() had got. An untagged error is recorded
|
||||
* against the step that was running, never guessed at.
|
||||
*/
|
||||
function fail(stage, message) {
|
||||
const err = new Error(message)
|
||||
err.stage = stage
|
||||
throw err
|
||||
}
|
||||
|
||||
// Table names a module may create despite not carrying its own id as a prefix.
|
||||
//
|
||||
// module-uo's twenty-seven tables predate the module system by two years, and
|
||||
// renaming live tables is a data migration this workstream deliberately does not
|
||||
// do (MODULE_SYSTEM.md §1.6). Grandfathering them by an explicit, per-module
|
||||
// allowlist keeps the prefix rule real for every module written after this one —
|
||||
// the alternative, dropping the rule, would leave the first name collision to be
|
||||
// discovered by a module silently adopting someone else's table.
|
||||
const LEGACY_TABLE_PREFIXES = { uo: ['shard_', 'uo_link_'] }
|
||||
|
||||
const CREATE_TABLE = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`"]?(\w+)[`"]?/gi
|
||||
|
||||
/** Table names core's own schema.sql declares — a module may not touch these. */
|
||||
let coreTables = null
|
||||
function coreTableNames() {
|
||||
if (coreTables) return coreTables
|
||||
coreTables = new Set()
|
||||
try {
|
||||
const sql = fs.readFileSync(path.join(__dirname, '..', '..', 'db', 'schema.sql'), 'utf8')
|
||||
for (const m of sql.matchAll(CREATE_TABLE)) coreTables.add(m[1].toLowerCase())
|
||||
} catch (err) {
|
||||
log.warn('could not read core schema for the table-collision check', { message: err.message })
|
||||
}
|
||||
return coreTables
|
||||
}
|
||||
|
||||
// The only leading verbs a fragment may use — an allowlist, not a DROP denylist.
|
||||
//
|
||||
// §2.6 bans `DROP`, but a denylist only ever bans what somebody thought of, and
|
||||
// core's own schema.sql needs exactly four verbs: CREATE, ALTER, INSERT, UPDATE.
|
||||
// Anything else in a file that is REPLAYED ON EVERY BOOT is a mistake worth
|
||||
// failing on — TRUNCATE and DELETE would empty a table every restart, RENAME
|
||||
// would break on the second one, and GRANT/SET/USE are core's business, not a
|
||||
// module's. CREATE covers CREATE INDEX as well as CREATE TABLE.
|
||||
//
|
||||
// This is a leading-verb check and says so: `ALTER TABLE x DROP COLUMN y` passes
|
||||
// it. Catching that needs a SQL parser, which is a large dependency to take on
|
||||
// for a rule whose real job is stopping the obvious foot-gun early.
|
||||
const ALLOWED_VERBS = new Set(['CREATE', 'ALTER', 'INSERT', 'UPDATE'])
|
||||
|
||||
const CREATE_TABLE_ANY = /^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?/i
|
||||
const CREATE_TABLE_GUARDED = /^CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+/i
|
||||
|
||||
/**
|
||||
* Read and validate a module's schema fragment; return every table it declares.
|
||||
*
|
||||
* Validation happens HERE, at load time, and not in modules/schema.js where the
|
||||
* fragment is replayed, because every rule §2.6 states is knowable by reading
|
||||
* the file — no database required. Failing at load means a module with a bad
|
||||
* fragment never mounts at all (§4.4's first column: routes and nav simply
|
||||
* absent), rather than mounting, 503ing, and leaving whatever its fragment did
|
||||
* manage to execute behind it.
|
||||
*
|
||||
* Throws if the file is unreadable or breaks a rule.
|
||||
*/
|
||||
function tablesOf(dir, manifest) {
|
||||
if (!manifest.schema) return new Set()
|
||||
const file = path.join(dir, manifest.schema)
|
||||
const sql = fs.readFileSync(file, 'utf8')
|
||||
|
||||
for (const statement of splitStatements(sql)) {
|
||||
const verb = (statement.match(/^\w+/) || [''])[0].toUpperCase()
|
||||
if (!ALLOWED_VERBS.has(verb)) {
|
||||
throw new Error(`schema fragment statement starts with "${verb}" (allowed: ${[...ALLOWED_VERBS].join(', ')})`)
|
||||
}
|
||||
// A bare CREATE TABLE succeeds exactly once and fails every boot after it,
|
||||
// which presents as a module that worked until the first restart.
|
||||
if (CREATE_TABLE_ANY.test(statement) && !CREATE_TABLE_GUARDED.test(statement)) {
|
||||
throw new Error('schema fragment has a CREATE TABLE without IF NOT EXISTS')
|
||||
}
|
||||
}
|
||||
|
||||
return new Set([...sql.matchAll(CREATE_TABLE)].map((m) => m[1].toLowerCase()))
|
||||
}
|
||||
|
||||
function checkTableNames(id, tables) {
|
||||
const allowed = LEGACY_TABLE_PREFIXES[id] || []
|
||||
const core = coreTableNames()
|
||||
|
||||
for (const table of tables) {
|
||||
if (core.has(table)) throw new Error(`schema fragment declares core table "${table}"`)
|
||||
for (const other of modules.values()) {
|
||||
if (other.tables.has(table)) {
|
||||
throw new Error(`schema fragment declares "${table}", already owned by module "${other.id}"`)
|
||||
}
|
||||
}
|
||||
const prefixed = table.startsWith(`${id}_`) || allowed.some((p) => table.startsWith(p))
|
||||
if (!prefixed) throw new Error(`schema fragment table "${table}" is not prefixed "${id}_"`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Does core already own this prefix in this tier?
|
||||
*
|
||||
* Asked of the LIVE tier router rather than a hardcoded list, so the check
|
||||
* cannot drift the first time core adds a capability router — the spike's
|
||||
* hardcoded table was already one prefix stale when it was written. Modules are
|
||||
* loaded after every core mount, so the stack is complete by the time this runs,
|
||||
* and `layer.match` is express's own matcher rather than a second-guess at its
|
||||
* regexp grammar.
|
||||
*
|
||||
* Root-mounted layers are skipped: `use(noindex, requireAuth)` and the two
|
||||
* `use('/', singletonRouter)` mounts match every path, and counting them would
|
||||
* report every prefix as taken.
|
||||
*/
|
||||
function ownedByCore(tierRouter, prefix) {
|
||||
return (tierRouter.stack || []).some(
|
||||
(layer) => layer.regexp && !layer.regexp.fast_slash && layer.match(prefix),
|
||||
)
|
||||
}
|
||||
|
||||
// ── The client chunk ───────────────────────────────────────────────────────
|
||||
|
||||
// A chunk filename, and the same character set utils/htmlShell.js will accept in
|
||||
// a script src. Two copies of the rule, deliberately: this one rejects the module
|
||||
// at load time, that one refuses to write the tag. A validator three files away
|
||||
// staying strict is not something an HTML attribute should depend on.
|
||||
const CHUNK_FILE = /^[A-Za-z0-9][A-Za-z0-9._-]*\.js$/
|
||||
|
||||
/**
|
||||
* Resolve and validate `client.entry` — where a module's prebuilt chunk lives on
|
||||
* disk, and the URL it is served at (§3.1).
|
||||
*
|
||||
* The rule that matters most is the last one, and it is the one a reviewer would
|
||||
* not think to ask for: the static mount is rooted at the DIRECTORY THE ENTRY IS
|
||||
* IN, not at the module root. One `express.static` over a module root would
|
||||
* publish its server source, its `module.json` and its schema fragment to the
|
||||
* internet. So an entry sitting directly in the module root is rejected rather
|
||||
* than quietly turning the whole module into a public directory.
|
||||
*
|
||||
* @returns {{dir: string, url: string, entryUrl: string}|null} null when the
|
||||
* module ships no client half — a server-only module is perfectly normal.
|
||||
*/
|
||||
function resolveClient(dir, id, manifest) {
|
||||
// Absent `client` is a server-only module. Present but empty is not the same
|
||||
// thing: it states a client half and delivers none, which would be a module
|
||||
// whose pages never load and nothing anywhere saying why.
|
||||
if (manifest.client === undefined) return null
|
||||
const { entry } = manifest.client
|
||||
if (typeof entry !== 'string' || !entry.trim()) fail('manifest', 'client.entry must be a path')
|
||||
|
||||
const file = path.resolve(dir, entry)
|
||||
// Containment before anything else: `../../server/src/config` resolves to a
|
||||
// real, readable directory, and every check below it would pass.
|
||||
if (file !== dir && !file.startsWith(dir + path.sep)) {
|
||||
fail('manifest', `client.entry "${entry}" escapes the module directory`)
|
||||
}
|
||||
if (!CHUNK_FILE.test(path.basename(file))) {
|
||||
fail('manifest', `client.entry "${entry}" must name a .js file`)
|
||||
}
|
||||
const chunkDir = path.dirname(file)
|
||||
if (chunkDir === dir) {
|
||||
fail('manifest', `client.entry "${entry}" must be in a subdirectory — its directory is served`)
|
||||
}
|
||||
if (!fs.existsSync(file)) fail('manifest', `client.entry "${entry}" is missing`)
|
||||
|
||||
return {
|
||||
dir: chunkDir,
|
||||
url: `/modules/${id}`,
|
||||
entryUrl: `/modules/${id}/${path.basename(file)}`,
|
||||
}
|
||||
}
|
||||
|
||||
function readManifest(dir, id, tierRouters) {
|
||||
const file = path.join(dir, 'module.json')
|
||||
const manifest = JSON.parse(fs.readFileSync(file, 'utf8'))
|
||||
|
||||
for (const key of Object.keys(manifest)) {
|
||||
// Rejected, not ignored: a typo'd key must be a loud failure rather than a
|
||||
// silently inert setting the operator believes they configured.
|
||||
if (!MANIFEST_KEYS.has(key)) fail('manifest', `unknown key "${key}" in module.json`)
|
||||
}
|
||||
if (!ID.test(manifest.id || '')) fail('manifest', `invalid id "${manifest.id}"`)
|
||||
if (manifest.id !== id) fail('manifest', `id "${manifest.id}" does not match directory "${id}"`)
|
||||
if (!manifest.version) fail('manifest', 'missing version')
|
||||
if (!manifest.coreApi) fail('core_api', 'missing coreApi')
|
||||
if (!semver.satisfies(MODULE_API_VERSION, manifest.coreApi)) {
|
||||
fail('core_api', `needs core API ${manifest.coreApi}, this core is ${MODULE_API_VERSION}`)
|
||||
}
|
||||
|
||||
for (const [tier, prefixes] of Object.entries(manifest.mounts || {})) {
|
||||
if (!TIERS.includes(tier)) fail('mounts', `unknown tier "${tier}" in mounts`)
|
||||
for (const prefix of prefixes) {
|
||||
if (!PREFIX.test(prefix)) fail('mounts', `bad prefix "${prefix}" in mounts.${tier}`)
|
||||
if (ownedByCore(tierRouters[tier], prefix)) {
|
||||
fail('mounts', `prefix ${tier}${prefix} is owned by core`)
|
||||
}
|
||||
for (const other of modules.values()) {
|
||||
if ((other.manifest.mounts?.[tier] || []).includes(prefix)) {
|
||||
fail('mounts', `prefix ${tier}${prefix} already registered by module "${other.id}"`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const slot of manifest.extensions || []) {
|
||||
if (!registries.hasSlot(slot)) fail('extensions', `unknown extension slot "${slot}"`)
|
||||
}
|
||||
|
||||
if (manifest.client !== undefined) {
|
||||
if (typeof manifest.client !== 'object' || manifest.client === null || Array.isArray(manifest.client)) {
|
||||
fail('manifest', 'client must be an object')
|
||||
}
|
||||
for (const key of Object.keys(manifest.client)) {
|
||||
if (key !== 'entry') fail('manifest', `unknown key "client.${key}" in module.json`)
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest.schema && !manifest.purge) {
|
||||
// A module that can create tables and cannot drop them leaves an operator
|
||||
// with orphaned data and no supported way to remove it.
|
||||
fail('schema', 'declares schema but no purge')
|
||||
}
|
||||
if (manifest.purge && !fs.existsSync(path.join(dir, manifest.purge))) {
|
||||
fail('schema', `purge file "${manifest.purge}" is missing`)
|
||||
}
|
||||
return manifest
|
||||
}
|
||||
|
||||
// What the module registered must equal what it declared — in both directions.
|
||||
function checkDeclared(record) {
|
||||
const declared = record.manifest.mounts || {}
|
||||
for (const tier of TIERS) {
|
||||
const want = new Set(declared[tier] || [])
|
||||
const got = new Set(record.routes[tier].keys())
|
||||
for (const p of got) if (!want.has(p)) throw new Error(`registered ${tier}${p} without declaring it`)
|
||||
for (const p of want) if (!got.has(p)) throw new Error(`declared ${tier}${p} but never registered it`)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Load ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Discover, validate, register and mount every module under MODULES_DIR.
|
||||
*
|
||||
* **Called exactly once, explicitly, from app.js**, after the three tier routers
|
||||
* are required and before the app is exported. There is no lazy self-scan: the
|
||||
* spike's was lazy and silent, so requiring the loader and reading the module
|
||||
* list gave an empty array and no error (MODULE_API.md §7.6). Everything that
|
||||
* reads the module list now throws until this has run.
|
||||
*
|
||||
* The ordering is not incidental. Core's mounts must already be on the tier
|
||||
* routers, because that is what the prefix-collision check is asked about; and
|
||||
* modules mount after them, so first-match-wins means a module could not shadow
|
||||
* a core prefix even if the check were bypassed.
|
||||
*
|
||||
* Safe to call when the modules directory does not exist — that is the normal
|
||||
* case for a bare core, and it is the state this PR ships in.
|
||||
*
|
||||
* @param {{public: Router, admin: Router, player: Router}} tierRouters
|
||||
*/
|
||||
function load(tierRouters) {
|
||||
if (loaded) return
|
||||
for (const tier of TIERS) {
|
||||
if (!tierRouters || typeof tierRouters[tier] !== 'function') {
|
||||
throw new Error(`modules.load: missing the "${tier}" tier router`)
|
||||
}
|
||||
}
|
||||
loaded = true
|
||||
|
||||
let entries = []
|
||||
try {
|
||||
entries = fs.readdirSync(MODULES_DIR, { withFileTypes: true })
|
||||
.filter((e) => e.isDirectory())
|
||||
.map((e) => e.name)
|
||||
.sort() // alphabetical: there is no dependency resolution, and any other
|
||||
// order would imply a precedence nothing computes (§4.2)
|
||||
} catch {
|
||||
return // no modules directory is the normal case for a bare core
|
||||
}
|
||||
|
||||
for (const id of entries) {
|
||||
const dir = path.join(MODULES_DIR, id)
|
||||
if (!fs.existsSync(path.join(dir, 'module.json'))) continue
|
||||
|
||||
const record = {
|
||||
id,
|
||||
dir,
|
||||
manifest: null,
|
||||
routes: { public: new Map(), admin: new Map(), player: new Map() },
|
||||
staged: registries.stage(id),
|
||||
tables: new Set(),
|
||||
called: new Set(),
|
||||
hooks: { onBoot: null, onShutdown: null },
|
||||
client: null,
|
||||
ctx: null,
|
||||
state: 'installed',
|
||||
stage: null,
|
||||
reason: null,
|
||||
}
|
||||
|
||||
// How far load() has got, so an untagged throw is recorded against the step
|
||||
// that was actually running (§4.3's steps 5-7). The steps before it label
|
||||
// themselves, because readManifest covers four of them in one pass.
|
||||
let stage = 'manifest'
|
||||
try {
|
||||
record.manifest = readManifest(dir, id, tierRouters)
|
||||
record.client = resolveClient(dir, id, record.manifest)
|
||||
stage = 'schema'
|
||||
record.tables = tablesOf(dir, record.manifest)
|
||||
checkTableNames(id, record.tables)
|
||||
if (record.manifest.server) {
|
||||
const entry = path.join(dir, record.manifest.server)
|
||||
stage = 'require'
|
||||
// eslint-disable-next-line global-require, import/no-dynamic-require
|
||||
const register = require(entry)
|
||||
if (typeof register !== 'function') throw new Error(`${record.manifest.server} does not export a function`)
|
||||
stage = 'register'
|
||||
// Kept on the record, not discarded after register(): §2.5 hands the
|
||||
// same ctx to onBoot, and building a second one would be a second frozen
|
||||
// object claiming to be the same handle.
|
||||
record.ctx = buildCtx(id, dir)
|
||||
register(record.ctx, buildApi(record))
|
||||
checkDeclared(record)
|
||||
}
|
||||
record.state = 'registered'
|
||||
modules.set(id, record)
|
||||
log.info(`registered module "${id}" v${record.manifest.version}`, {
|
||||
mounts: record.manifest.mounts,
|
||||
})
|
||||
} catch (err) {
|
||||
// A failure here is BEFORE any route was mounted, so this module's routes
|
||||
// and nav are simply absent and the site comes up without it (§4.4).
|
||||
record.state = 'startup_failed'
|
||||
record.stage = err.stage || stage
|
||||
record.reason = err.message
|
||||
record.manifest = record.manifest || { id, version: 'unknown' }
|
||||
modules.set(id, record)
|
||||
log.error(`module "${id}" failed to load — continuing without it`, {
|
||||
stage: record.stage,
|
||||
reason: err.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Mounting is a SECOND pass, after every module has been validated, and not
|
||||
// because it reads better. `ownedByCore` asks the live tier router what is
|
||||
// already on it, so mounting inside the loop would make the first module's
|
||||
// layers indistinguishable from core's — the second module claiming a taken
|
||||
// prefix would be told it collided with core, naming the wrong culprit, and
|
||||
// the module-versus-module check below it could never be reached.
|
||||
for (const record of modules.values()) {
|
||||
if (record.state !== 'registered') continue
|
||||
try {
|
||||
// Commit what this module staged. Collisions with core or with an earlier
|
||||
// module surface here, in scan order, and cost only this module.
|
||||
registries.apply(record.staged.staged)
|
||||
} catch (err) {
|
||||
record.state = 'startup_failed'
|
||||
record.stage = 'register'
|
||||
record.reason = err.message
|
||||
log.error(`module "${record.id}" failed to register — continuing without it`, {
|
||||
reason: err.message,
|
||||
})
|
||||
continue // unmounted, exactly like a validation failure in the first pass
|
||||
}
|
||||
mount(record, tierRouters)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount one module's routers onto the tier routers, behind the dispatch guard.
|
||||
*
|
||||
* The guard is the other half of §4.4. A module that fails BEFORE this point has
|
||||
* no routes at all; one that fails after — schema replay (PR 3), `onBoot`
|
||||
* (PR 5) — keeps its URLs and answers 503, so `routes.manifest.json` never
|
||||
* depends on whether a boot hook happened to succeed on the machine that
|
||||
* generated it. `disabled` is 404 and unreachable until PR 5 wires
|
||||
* `installed_modules` in; it is written here because the guard is the contract's
|
||||
* §4.5, not a later addition.
|
||||
*/
|
||||
function mount(record, tierRouters) {
|
||||
for (const tier of TIERS) {
|
||||
for (const [prefix, router] of record.routes[tier]) {
|
||||
tierRouters[tier].use(prefix, stateGuard(record), router)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The dispatch guard, as a middleware over the LIVE record.
|
||||
*
|
||||
* A closure over the record rather than over its state: everything mounts once,
|
||||
* at boot, and the states that matter here are reached afterwards — the schema
|
||||
* replay fails, `onBoot` throws, an admin disables the module. A guard that read
|
||||
* the state at mount time would answer for the state a module was in before any
|
||||
* of that happened.
|
||||
*
|
||||
* Used for a module's API routes and, since PR 7, for its client chunk: a module
|
||||
* answering 503 on its API must not also be handing the browser the script that
|
||||
* calls it, and one an admin has disabled should be as absent from the page as it
|
||||
* is from the nav.
|
||||
*/
|
||||
function stateGuard(record) {
|
||||
return (req, res, next) => {
|
||||
if (record.state === 'startup_failed') {
|
||||
return res.status(503).json({ message: 'Module unavailable' })
|
||||
}
|
||||
if (record.state === 'disabled') return res.status(404).json({ message: 'Not found' })
|
||||
return next()
|
||||
}
|
||||
}
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// The states a loaded record may hold, deliberately a hardcoded subset rather
|
||||
// than an import of model/modules/modules.model.js's STATES: that model reaches
|
||||
// the database, and this file must stay require-able against a dead one.
|
||||
// `installed` is not here because a record leaves load() resolved either way.
|
||||
const RECORD_STATES = new Set(['registered', 'started', 'disabled', 'startup_failed'])
|
||||
|
||||
/**
|
||||
* Move a loaded module to a new state — the POST-mount transitions.
|
||||
*
|
||||
* Called by whoever ran the step that failed or the step that succeeded, because
|
||||
* only they can know: `ensureSchema()` replays the fragments (PR 3), and
|
||||
* lifecycle.js runs `onBoot` and reconciles `installed_modules` (whose `disabled`
|
||||
* rows are what make the guard's 404 leg reachable).
|
||||
*
|
||||
* The stage travels with the reason and is cleared by every non-failing move,
|
||||
* for the same reason the database columns are (§2.4): a running module must
|
||||
* never be able to show a stale failure.
|
||||
*
|
||||
* Unknown ids are ignored rather than thrown on: a module can be absent from the
|
||||
* volume and still have a row, and a caller on the boot path must not turn that
|
||||
* into everyone's failure.
|
||||
*/
|
||||
function setState(id, state, { stage = null, reason = null } = {}) {
|
||||
if (!RECORD_STATES.has(state)) throw new Error(`unknown module state "${state}"`)
|
||||
const record = modules.get(id)
|
||||
if (!record) return
|
||||
record.state = state
|
||||
record.stage = state === 'startup_failed' ? stage : null
|
||||
record.reason = state === 'startup_failed' ? reason : null
|
||||
}
|
||||
|
||||
// ── Introspection ──────────────────────────────────────────────────────────
|
||||
|
||||
function assertLoaded(caller) {
|
||||
if (!loaded) throw new Error(`modules.${caller}() before modules.load()`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Has load() run in this process?
|
||||
*
|
||||
* The one legitimate reason to ask instead of just calling an accessor: a
|
||||
* process that never required app.js and so has no module list to be wrong
|
||||
* about. `npm run seed` (db/seed.js) is exactly that — it calls ensureSchema()
|
||||
* standalone, and the fragment replay has to be able to tell "this is the seed
|
||||
* script" from "the server booted and something is mis-ordered", which is the
|
||||
* distinction §7.6's throw exists to preserve everywhere else.
|
||||
*/
|
||||
const isLoaded = () => loaded
|
||||
|
||||
/**
|
||||
* Every module found on the volume, loaded or failed, in scan order.
|
||||
*
|
||||
* Throws rather than returning `[]` when load() has not run — the empty list is
|
||||
* a real answer for a core with no modules installed, and a caller cannot tell
|
||||
* the two apart (§7.6).
|
||||
*/
|
||||
function list() {
|
||||
assertLoaded('list')
|
||||
return [...modules.values()].map((r) => ({
|
||||
id: r.id,
|
||||
name: r.manifest.name || r.id,
|
||||
version: r.manifest.version,
|
||||
state: r.state,
|
||||
stage: r.stage,
|
||||
reason: r.reason,
|
||||
capabilities: r.manifest.capabilities || [],
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* The modules that are ready to be booted, with their hook, in scan order.
|
||||
*
|
||||
* `registered` only — the state a module holds between a clean load and its
|
||||
* `onBoot`. One that failed validation or schema replay is not going to run, and
|
||||
* one already `started` has run. A module with no `onBoot` is still listed: it
|
||||
* has nothing to warm up, but it still has to reach `started` so the admin panel
|
||||
* and `installed_modules` agree with the guard about what is serving.
|
||||
*
|
||||
* @returns {{id: string, hook: Function|null, ctx: object|null}[]}
|
||||
*/
|
||||
function bootable() {
|
||||
assertLoaded('bootable')
|
||||
return [...modules.values()]
|
||||
.filter((r) => r.state === 'registered')
|
||||
.map((r) => ({ id: r.id, hook: r.hooks.onBoot, ctx: r.ctx }))
|
||||
}
|
||||
|
||||
/**
|
||||
* The shutdown hooks to run, in REVERSE registration order (§2.5).
|
||||
*
|
||||
* `started` only. A module whose `onBoot` threw is mid-way through a warm-up it
|
||||
* never finished, and calling its `onShutdown` would hand it a half-built world
|
||||
* to tear down — the one thing worse than not closing cleanly. Reverse order is
|
||||
* the same reasoning applied between modules rather than within one.
|
||||
*
|
||||
* @returns {{id: string, hook: Function}[]}
|
||||
*/
|
||||
function shutdownHooks() {
|
||||
assertLoaded('shutdownHooks')
|
||||
return [...modules.values()]
|
||||
.filter((r) => r.state === 'started' && r.hooks.onShutdown)
|
||||
.map((r) => ({ id: r.id, hook: r.hooks.onShutdown }))
|
||||
.reverse()
|
||||
}
|
||||
|
||||
/**
|
||||
* Every schema fragment waiting to be replayed, in scan order.
|
||||
*
|
||||
* `registered` only: a module that failed validation must not get its tables
|
||||
* created (it is not going to run), and one already `started` has had them. The
|
||||
* absolute path is resolved here rather than handed out as a manifest-relative
|
||||
* name, so the replay never has to know how a module directory is laid out.
|
||||
*
|
||||
* @returns {{id: string, file: string}[]}
|
||||
*/
|
||||
function fragments() {
|
||||
assertLoaded('fragments')
|
||||
return [...modules.values()]
|
||||
.filter((r) => r.state === 'registered' && r.manifest.schema)
|
||||
.map((r) => ({ id: r.id, file: path.join(r.dir, r.manifest.schema) }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Every module that ships a client chunk, with where to serve it from and the
|
||||
* guard to serve it behind — in scan order.
|
||||
*
|
||||
* Listed regardless of state, because mounting happens once at boot and the
|
||||
* guard is what answers for the state at request time (the same arrangement the
|
||||
* API routes have). A module that failed VALIDATION never reaches here at all:
|
||||
* `record.client` is only resolved once the manifest passed.
|
||||
*
|
||||
* `dir` is the directory the entry sits in, never the module root — see
|
||||
* resolveClient. app.js does the mounting; this file does not know about the
|
||||
* root app.
|
||||
*
|
||||
* @returns {{id: string, dir: string, url: string, entryUrl: string, guard: Function}[]}
|
||||
*/
|
||||
function clientChunks() {
|
||||
assertLoaded('clientChunks')
|
||||
return [...modules.values()]
|
||||
.filter((r) => r.client)
|
||||
.map((r) => ({ id: r.id, ...r.client, guard: stateGuard(r) }))
|
||||
}
|
||||
|
||||
/**
|
||||
* The script URLs the HTML shell should inject, in scan order.
|
||||
*
|
||||
* `started` only, and that is the difference between this and clientChunks():
|
||||
* the mount is a standing offer answered by a guard, while the tag is a decision
|
||||
* taken per page render, when the state is already known. A module whose `onBoot`
|
||||
* failed keeps its URLs and answers 503 on them — loading its client half would
|
||||
* render its pages against a backend that cannot serve them.
|
||||
*
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function clientEntryUrls() {
|
||||
assertLoaded('clientEntryUrls')
|
||||
return [...modules.values()]
|
||||
.filter((r) => r.client && r.state === 'started')
|
||||
.map((r) => r.client.entryUrl)
|
||||
}
|
||||
|
||||
/** Absolute path of the modules directory. */
|
||||
const dir = () => MODULES_DIR
|
||||
|
||||
module.exports = {
|
||||
load,
|
||||
list,
|
||||
setState,
|
||||
fragments,
|
||||
bootable,
|
||||
shutdownHooks,
|
||||
clientChunks,
|
||||
clientEntryUrls,
|
||||
isLoaded,
|
||||
dir,
|
||||
}
|
||||
358
server/src/modules/registries.js
Normal file
358
server/src/modules/registries.js
Normal file
@@ -0,0 +1,358 @@
|
||||
// ── The de-entanglement registries ─────────────────────────────────────────
|
||||
//
|
||||
// Phase 2, PR 4 of docs/website/MODULE_SYSTEM.md §2.7 — the three seams §1.8 and
|
||||
// §1.9 identified, where core code and game-specific content are tangled in one
|
||||
// file and a folder move cannot separate them. The normative contract is
|
||||
// docs/website/MODULE_API.md §2.4.
|
||||
//
|
||||
// The three:
|
||||
//
|
||||
// 1. `registerExtension(slot, router)` — §1.9. Module routes hanging off a
|
||||
// CORE resource (`/admin/users/:id`), so all six shard sub-paths keep their
|
||||
// URLs while core never learns what "shard" means.
|
||||
// 2. `registerNotificationStreams(streams)` — §1.8. The push-stream catalog:
|
||||
// push INFRASTRUCTURE is core, this CATALOG is content.
|
||||
// 3. `registerAnnounceLeg({ leg, label, dispatch, classify })` — §1.8. The news
|
||||
// dispatcher's delivery legs; Discord is core, town crier is content.
|
||||
//
|
||||
// **Core registers through these functions too, and is the only registrant until
|
||||
// Phase 3.** `registerCore()` below is called explicitly from app.js before
|
||||
// `modules.load()` — explicit, never lazy, the same decision the loader's trigger
|
||||
// took (MODULE_API.md §7.6). Core going through the same door is the point: a
|
||||
// registry only core's hardcoded base bypasses is a registry whose first real
|
||||
// exercise is a module, which is the drift this PR exists to prevent.
|
||||
//
|
||||
// **Registering is validate-then-commit, per registrant.** `apply()` checks every
|
||||
// claim in a batch before it writes any of them, so a module that registers two
|
||||
// streams and then throws — or fails a later validation step in the loader — has
|
||||
// left nothing behind. That is the registry-side twin of the loader's second-pass
|
||||
// mount rule: nothing a module claims takes effect until the module as a whole is
|
||||
// known good.
|
||||
//
|
||||
// Nothing here reaches the database or the network. It is a require-time-safe
|
||||
// collection of what core and modules have declared, read at request time.
|
||||
|
||||
const express = require('express')
|
||||
|
||||
const log = require('../utils/logger')('modules')
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// slot → { router, filledBy }. `router` is created when CORE DECLARES the slot
|
||||
// and mounted immediately; registrants `use()` into it later. That indirection is
|
||||
// not optional: users.router.js is required while app.js is being built, long
|
||||
// before any module has been scanned, so the thing core mounts has to be a stable
|
||||
// object that can still be empty.
|
||||
const slots = new Map()
|
||||
|
||||
// Registration order, which is display order in the app's notifications screen.
|
||||
const streams = []
|
||||
const streamOwners = new Map() // stream id → owner id, for the collision message
|
||||
|
||||
// leg id → { owner, leg, label, dispatch, classify }
|
||||
const legs = new Map()
|
||||
|
||||
let coreRegistered = false
|
||||
|
||||
// Stream ids that predate the module system and may not carry their owner's
|
||||
// prefix — the exact counterpart of the loader's LEGACY_TABLE_PREFIXES, for the
|
||||
// exact same reason. These seven ids are stored in `notification_subs` rows and
|
||||
// are read by a shipped Android client; renaming them in Phase 3 would be a data
|
||||
// migration and a client break, so `uo` keeps them and the prefix rule stays real
|
||||
// for every module written after it.
|
||||
const LEGACY_STREAM_IDS = {
|
||||
uo: [
|
||||
'server.status', 'idoc.warning', 'champ.start', 'governor.election',
|
||||
'vendor.sale', 'house.idoc', 'account.login',
|
||||
],
|
||||
}
|
||||
|
||||
// Likewise for announce legs: `towncrier` is a stored value in
|
||||
// announce_job_legs.leg and the body of the admin retry endpoint.
|
||||
const LEGACY_LEGS = { uo: ['towncrier'] }
|
||||
|
||||
const STREAM_ID = /^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)+$/
|
||||
const LEG_ID = /^[a-z][a-z0-9.]{1,62}$/
|
||||
|
||||
// A module's claim must carry its id. Core's ids are its own namespace, and the
|
||||
// grandfathered names are the ones that predate all of this.
|
||||
function namespaced(owner, name, legacy) {
|
||||
return owner === 'core' || name.startsWith(`${owner}.`) || (legacy[owner] || []).includes(name)
|
||||
}
|
||||
|
||||
// ── Extension slots (§1.9) ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Core declares an extension slot and gets the router to mount for it.
|
||||
*
|
||||
* ONLY core may declare a slot; a module may only fill one (MODULE_API.md §2.4).
|
||||
* That asymmetry is why this is not on the `api` object handed to a module.
|
||||
*
|
||||
* `mergeParams` so the slot's router sees the parent's `:id`. Core's own routes
|
||||
* on the resource are declared before the slot is mounted, so first-match-wins
|
||||
* gives core the path conflict, as the contract requires.
|
||||
*
|
||||
* @returns {import('express').Router} mount this at the resource, once.
|
||||
*/
|
||||
function declareSlot(slot) {
|
||||
if (slots.has(slot)) throw new Error(`extension slot "${slot}" already declared`)
|
||||
const router = express.Router({ mergeParams: true })
|
||||
slots.set(slot, { router, filledBy: null })
|
||||
return router
|
||||
}
|
||||
|
||||
/** Does this slot exist? The loader asks, to validate `extensions` in a manifest. */
|
||||
const hasSlot = (slot) => slots.has(slot)
|
||||
|
||||
/** Who filled a slot, or null. */
|
||||
const slotFilledBy = (slot) => (slots.get(slot) || {}).filledBy || null
|
||||
|
||||
/**
|
||||
* Every FILLED slot, for the OpenAPI build step (swagger/slotSpecs.js).
|
||||
*
|
||||
* `router` is the slot's own stable router — the object mounted on the resource —
|
||||
* so the build can find it in the live express stack and recover the prefix it
|
||||
* hangs at without a hardcoded table.
|
||||
*/
|
||||
const filledSlots = () =>
|
||||
[...slots.entries()]
|
||||
.filter(([, e]) => e.filledBy)
|
||||
.map(([slot, e]) => ({ slot, filledBy: e.filledBy, router: e.router, specFile: e.specFile || null }))
|
||||
|
||||
// ── Notification streams (§1.8) ────────────────────────────────────────────
|
||||
|
||||
/** The whole catalog, core's entries first, in registration order. */
|
||||
const allStreams = () => streams.slice()
|
||||
|
||||
/** Is this a stream anyone registered? Gates a subscription write. */
|
||||
const isValidStream = (id) => streamOwners.has(id)
|
||||
|
||||
/** Ids of the owner-keyed streams — those needing a linked game account. */
|
||||
const personalStreams = () => new Set(streams.filter((s) => s.personal).map((s) => s.id))
|
||||
|
||||
// ── Announce legs (§1.8) ───────────────────────────────────────────────────
|
||||
|
||||
/** Every registered leg, in registration order. */
|
||||
const announceLegs = () => [...legs.values()]
|
||||
|
||||
/** Just the ids — the enqueue order and the retry endpoint's allowlist. */
|
||||
const announceLegIds = () => [...legs.keys()]
|
||||
|
||||
/** One leg, or null. */
|
||||
const announceLeg = (leg) => legs.get(leg) || null
|
||||
|
||||
// ── Shape checks, run the moment a registrant calls ────────────────────────
|
||||
//
|
||||
// Split from the collision checks below on the same line PR 3 drew through
|
||||
// schema-fragment validation: what can be decided from the argument alone is
|
||||
// decided AT THE CALL, so the error carries the registrant's own stack. What
|
||||
// depends on other registrants has to wait for the batch to be complete.
|
||||
|
||||
function checkStreamShape(entry) {
|
||||
if (!entry || !STREAM_ID.test(entry.id || '')) {
|
||||
throw new Error(`registerNotificationStreams: bad stream id "${entry && entry.id}"`)
|
||||
}
|
||||
if (!entry.label) throw new Error(`registerNotificationStreams: stream "${entry.id}" has no label`)
|
||||
return {
|
||||
id: entry.id,
|
||||
label: entry.label,
|
||||
description: entry.description || '',
|
||||
personal: Boolean(entry.personal),
|
||||
requiresLinkedAccount: Boolean(entry.requiresLinkedAccount),
|
||||
}
|
||||
}
|
||||
|
||||
function checkLegShape(entry) {
|
||||
const { leg, label, dispatch, classify } = entry || {}
|
||||
if (!LEG_ID.test(leg || '')) throw new Error(`registerAnnounceLeg: bad leg id "${leg}"`)
|
||||
if (typeof dispatch !== 'function') throw new Error(`announce leg "${leg}" has no dispatch()`)
|
||||
if (typeof classify !== 'function') throw new Error(`announce leg "${leg}" has no classify()`)
|
||||
return { leg, label: label || leg, dispatch, classify }
|
||||
}
|
||||
|
||||
// `specFile` is CORE-ONLY and is not on the module-facing signature. A slot's
|
||||
// router reaches the app through declareSlot(), which no static parse of app.js
|
||||
// can follow, so swagger-autogen would silently drop every route in it — the
|
||||
// spike's exact failure (MODULE_API.md §7.4). Core names the file so
|
||||
// `npm run swagger` can generate a fragment from it and merge it into the
|
||||
// committed spec. A MODULE has no equivalent need: it ships a prebuilt
|
||||
// `swagger-fragment.json` in its bundle (§6.1a), because core never has its
|
||||
// sources to analyse.
|
||||
function checkExtensionShape(slot, router, specFile) {
|
||||
if (!slots.has(slot)) throw new Error(`unknown extension slot "${slot}"`)
|
||||
if (typeof router !== 'function') throw new Error(`registerExtension: ${slot} is not a router`)
|
||||
return { slot, router, specFile: specFile || null }
|
||||
}
|
||||
|
||||
// ── Staging + commit ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A registrant's staging area: shape-checked claims, not yet visible to anyone.
|
||||
*
|
||||
* The loader hands one of these to a module through `api`, and `registerCore()`
|
||||
* builds one for core. Nothing a registrant says is readable through
|
||||
* `allStreams()` / `announceLeg()` / the slot routers until `apply()`.
|
||||
*/
|
||||
function stage(owner) {
|
||||
const staged = { owner, streams: [], legs: [], extensions: [] }
|
||||
return {
|
||||
staged,
|
||||
registerNotificationStreams(entries) {
|
||||
if (!Array.isArray(entries)) throw new Error('registerNotificationStreams: expected an array')
|
||||
for (const e of entries) staged.streams.push(checkStreamShape(e))
|
||||
},
|
||||
registerAnnounceLeg(entry) {
|
||||
staged.legs.push(checkLegShape(entry))
|
||||
},
|
||||
registerExtension(slot, router, specFile) {
|
||||
staged.extensions.push(checkExtensionShape(slot, router, specFile))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a staged batch against everything already registered, then commit it.
|
||||
*
|
||||
* Validation is TOTAL before the first write, so this either takes all of a
|
||||
* registrant's claims or none of them. Throws on the first collision, naming who
|
||||
* holds the thing already — which is the message an operator needs and the one
|
||||
* PR 2 learned to protect (mounting inside the scan loop made every collision
|
||||
* look like it was with core).
|
||||
*/
|
||||
function apply({ owner, streams: newStreams, legs: newLegs, extensions: newExtensions }) {
|
||||
// ── validate ──
|
||||
const seenStreams = new Set()
|
||||
for (const s of newStreams) {
|
||||
const held = streamOwners.get(s.id)
|
||||
if (held) throw new Error(`stream "${s.id}" is already registered by "${held}"`)
|
||||
if (seenStreams.has(s.id)) throw new Error(`stream "${s.id}" registered twice`)
|
||||
if (!namespaced(owner, s.id, LEGACY_STREAM_IDS)) {
|
||||
throw new Error(`stream "${s.id}" is not namespaced "${owner}."`)
|
||||
}
|
||||
seenStreams.add(s.id)
|
||||
}
|
||||
|
||||
const seenLegs = new Set()
|
||||
for (const l of newLegs) {
|
||||
const held = legs.get(l.leg)
|
||||
if (held) throw new Error(`announce leg "${l.leg}" is already registered by "${held.owner}"`)
|
||||
if (seenLegs.has(l.leg)) throw new Error(`announce leg "${l.leg}" registered twice`)
|
||||
if (!namespaced(owner, l.leg, LEGACY_LEGS)) {
|
||||
throw new Error(`announce leg "${l.leg}" is not namespaced "${owner}."`)
|
||||
}
|
||||
seenLegs.add(l.leg)
|
||||
}
|
||||
|
||||
const seenSlots = new Set()
|
||||
for (const x of newExtensions) {
|
||||
const entry = slots.get(x.slot)
|
||||
if (entry.filledBy) {
|
||||
throw new Error(`extension slot "${x.slot}" is already filled by "${entry.filledBy}"`)
|
||||
}
|
||||
if (seenSlots.has(x.slot)) throw new Error(`extension slot "${x.slot}" filled twice`)
|
||||
seenSlots.add(x.slot)
|
||||
}
|
||||
|
||||
// ── commit — nothing below can fail ──
|
||||
for (const s of newStreams) {
|
||||
streamOwners.set(s.id, owner)
|
||||
streams.push(s)
|
||||
}
|
||||
for (const l of newLegs) legs.set(l.leg, { owner, ...l })
|
||||
for (const x of newExtensions) {
|
||||
const entry = slots.get(x.slot)
|
||||
entry.filledBy = owner
|
||||
entry.specFile = x.specFile
|
||||
entry.router.use(x.router)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Core's own registrations ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Register everything CORE owns, through the same staging area a module uses.
|
||||
*
|
||||
* Called once from app.js, before `modules.load()` — before, because a module's
|
||||
* collision checks are asked against what is already registered, and core's
|
||||
* claims must be the ones already there.
|
||||
*
|
||||
* What is here is what survives Phase 3. Everything after the boundary comment is
|
||||
* shard content and leaves with module-uo, registered rather than hardcoded so
|
||||
* the seam is exercised on every boot long before a module first uses it.
|
||||
*/
|
||||
function registerCore() {
|
||||
if (coreRegistered) return
|
||||
|
||||
/* eslint-disable global-require */
|
||||
const coreStreams = require('../config/coreStreams')
|
||||
const discordLeg = require('../utils/discordAnnounce')
|
||||
const shardStreams = require('../config/shardStreams')
|
||||
const townCrierLeg = require('../utils/shardAnnounce')
|
||||
const shardExtension = require('../router/v1/admin/usersShard.router')
|
||||
/* eslint-enable global-require */
|
||||
|
||||
const api = stage('core')
|
||||
api.registerNotificationStreams(coreStreams.STREAMS)
|
||||
api.registerAnnounceLeg(discordLeg.leg)
|
||||
|
||||
// ── Phase 3 boundary ────────────────────────────────────────────────────
|
||||
// These three lines become module-uo's register() body, with 'core' becoming
|
||||
// 'uo'. Nothing else in core has to change for that to happen — which is the
|
||||
// whole claim PR 4 is making.
|
||||
api.registerNotificationStreams(shardStreams.STREAMS)
|
||||
api.registerAnnounceLeg(townCrierLeg.leg)
|
||||
// The third argument is core-only and has no module counterpart — see
|
||||
// checkExtensionShape. A module ships a prebuilt swagger-fragment.json instead.
|
||||
api.registerExtension('admin.users.detail', shardExtension, require.resolve('../router/v1/admin/usersShard.router'))
|
||||
|
||||
apply(api.staged)
|
||||
coreRegistered = true
|
||||
|
||||
log.info('core registrations complete', {
|
||||
streams: streams.length,
|
||||
announceLegs: legs.size,
|
||||
extensions: [...slots.keys()].filter(slotFilledBy),
|
||||
})
|
||||
}
|
||||
|
||||
/** Has registerCore() run? Read by tests, and by the loader's ordering assertion. */
|
||||
const isCoreRegistered = () => coreRegistered
|
||||
|
||||
// Test-only: hand the process back. Registries are process-global by design
|
||||
// (there is one core), so a test that registers has to be able to undo it.
|
||||
//
|
||||
// Slot DECLARATIONS survive, and only their fills are cleared: a slot is declared
|
||||
// at require time by the router that owns the resource, and that require has
|
||||
// already happened and will not happen again in this process. Clearing the map
|
||||
// would leave a slot that nothing can re-declare. The cost is that a test filling
|
||||
// the same slot twice stacks two routers inside it; no test reads through a slot
|
||||
// router, so that is left rather than papered over with a rebuilt router that
|
||||
// would no longer be the object users.router.js mounted.
|
||||
function _reset() {
|
||||
for (const entry of slots.values()) {
|
||||
entry.filledBy = null
|
||||
entry.specFile = null
|
||||
}
|
||||
streams.length = 0
|
||||
streamOwners.clear()
|
||||
legs.clear()
|
||||
coreRegistered = false
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
declareSlot,
|
||||
hasSlot,
|
||||
slotFilledBy,
|
||||
filledSlots,
|
||||
allStreams,
|
||||
isValidStream,
|
||||
personalStreams,
|
||||
announceLegs,
|
||||
announceLegIds,
|
||||
announceLeg,
|
||||
stage,
|
||||
apply,
|
||||
registerCore,
|
||||
isCoreRegistered,
|
||||
_reset,
|
||||
}
|
||||
84
server/src/modules/schema.js
Normal file
84
server/src/modules/schema.js
Normal file
@@ -0,0 +1,84 @@
|
||||
// ── Module schema fragment replay ──────────────────────────────────────────
|
||||
//
|
||||
// Phase 2, PR 3 of docs/website/MODULE_SYSTEM.md §2.7. Normative contract:
|
||||
// docs/website/MODULE_API.md §2.6 (fragments) and §4.4 (failure is a state).
|
||||
//
|
||||
// utils/db.js calls replayFragments() once, immediately after core's schema.sql
|
||||
// is in place and before seedDefaults(), so that by the time a module's onBoot
|
||||
// runs (PR 5) its tables exist.
|
||||
//
|
||||
// The split of responsibility with loader.js is worth stating, because it is the
|
||||
// reason there are two files:
|
||||
//
|
||||
// loader.js VALIDATES a fragment — at load time, with no database, before
|
||||
// anything is mounted. Every rule §2.6 states about the SQL is
|
||||
// knowable by reading it, so a fragment that breaks one costs the
|
||||
// module its mount entirely (§4.4, first column).
|
||||
// schema.js EXECUTES it. Only reachable failures live here: the database
|
||||
// rejecting a statement it could not have known was bad. Those are
|
||||
// post-mount, so they 503 (§4.4, second column).
|
||||
//
|
||||
// The property this file exists to keep: **a fragment that fails takes down its
|
||||
// own module and nothing else.** Not core's boot, not another module's tables.
|
||||
|
||||
const fs = require('fs')
|
||||
|
||||
const { splitStatements } = require('../utils/sqlStatements')
|
||||
|
||||
const log = require('../utils/logger')('modules')
|
||||
|
||||
/**
|
||||
* Replay every installed module's schema fragment, in scan order.
|
||||
*
|
||||
* Never throws. A module whose fragment fails is moved to `startup_failed` with
|
||||
* the database's own message as the reason, its routes answer 503 through the
|
||||
* dispatch guard the loader already mounted, and the next module is replayed as
|
||||
* if nothing happened.
|
||||
*
|
||||
* Partial application is accepted rather than compensated for: MariaDB commits
|
||||
* each DDL statement implicitly, so a fragment failing at statement three has
|
||||
* already created the first two tables and no wrapping transaction could undo
|
||||
* them. Since every statement is required to be idempotent (§2.6), the fix is
|
||||
* for the operator to correct the fragment and reboot — the surviving tables are
|
||||
* re-CREATE-IF-NOT-EXISTSed harmlessly and the replay carries on past them.
|
||||
*
|
||||
* @param {object} [deps] injection seam for tests — the whole point of this
|
||||
* function taking arguments at all, since the server suite runs with the pool
|
||||
* pointed at a dead port.
|
||||
* @param {(sql: string) => Promise<any>} [deps.query]
|
||||
* @param {object} [deps.modules] the loader
|
||||
*/
|
||||
async function replayFragments({ query, modules } = {}) {
|
||||
/* eslint-disable global-require */
|
||||
const run = query || require('../utils/db').query
|
||||
const loader = modules || require('./loader')
|
||||
/* eslint-enable global-require */
|
||||
|
||||
// Not an error: `npm run seed` calls ensureSchema() without ever requiring
|
||||
// app.js, so no scan has happened and there is genuinely nothing to replay.
|
||||
// Logged rather than silently skipped — the one thing that must not happen is
|
||||
// a booting server quietly getting no module tables (§7.6).
|
||||
if (!loader.isLoaded()) {
|
||||
log.info('no module scan in this process — skipping schema fragment replay')
|
||||
return
|
||||
}
|
||||
|
||||
for (const { id, file } of loader.fragments()) {
|
||||
try {
|
||||
const statements = splitStatements(fs.readFileSync(file, 'utf8'))
|
||||
for (const statement of statements) {
|
||||
// Serially, and awaited: a fragment's ALTER TABLE routinely depends on
|
||||
// the CREATE TABLE above it.
|
||||
await run(statement)
|
||||
}
|
||||
log.info(`schema ensured for module "${id}"`, { statements: statements.length })
|
||||
} catch (err) {
|
||||
loader.setState(id, 'startup_failed', { stage: 'schema', reason: err.message })
|
||||
log.error(`module "${id}" schema fragment failed — its routes will answer 503`, {
|
||||
reason: err.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { replayFragments }
|
||||
47
server/src/modules/semver.js
Normal file
47
server/src/modules/semver.js
Normal file
@@ -0,0 +1,47 @@
|
||||
// A deliberately tiny semver range check — enough for `coreApi` and no more.
|
||||
//
|
||||
// Supports `*`, an exact `x.y.z`, `^x.y.z` and `~x.y.z`. That is the whole
|
||||
// grammar a module manifest is allowed to use (MODULE_API.md §1.1), so pulling
|
||||
// in the `semver` package for it would add a dependency to the server for a
|
||||
// twenty-line job. A range this parser does not understand is REJECTED rather
|
||||
// than assumed to match — an unparseable range must not silently load a module
|
||||
// against an API it was never tested on.
|
||||
|
||||
const PARTS = /^(\d+)\.(\d+)\.(\d+)$/
|
||||
|
||||
function parse(version) {
|
||||
const m = PARTS.exec(String(version).trim())
|
||||
if (!m) return null
|
||||
return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]) }
|
||||
}
|
||||
|
||||
const gte = (a, b) => {
|
||||
if (a.major !== b.major) return a.major > b.major
|
||||
if (a.minor !== b.minor) return a.minor > b.minor
|
||||
return a.patch >= b.patch
|
||||
}
|
||||
|
||||
/**
|
||||
* Does `version` satisfy `range`?
|
||||
* @param {string} version an exact x.y.z
|
||||
* @param {string} range `*` | `x.y.z` | `^x.y.z` | `~x.y.z`
|
||||
* @returns {boolean} false for anything unparseable, on either side
|
||||
*/
|
||||
function satisfies(version, range) {
|
||||
const v = parse(version)
|
||||
if (!v) return false
|
||||
const raw = String(range).trim()
|
||||
if (raw === '*') return true
|
||||
|
||||
const op = raw[0] === '^' || raw[0] === '~' ? raw[0] : ''
|
||||
const b = parse(op ? raw.slice(1) : raw)
|
||||
if (!b) return false
|
||||
|
||||
if (op === '') return v.major === b.major && v.minor === b.minor && v.patch === b.patch
|
||||
if (!gte(v, b)) return false
|
||||
// ^ allows minor+patch within the same major; ~ allows patch within the same minor.
|
||||
if (op === '^') return v.major === b.major
|
||||
return v.major === b.major && v.minor === b.minor
|
||||
}
|
||||
|
||||
module.exports = { satisfies, parse }
|
||||
14
server/src/modules/version.js
Normal file
14
server/src/modules/version.js
Normal file
@@ -0,0 +1,14 @@
|
||||
// The module API version — the single number a module's `coreApi` range is
|
||||
// checked against (docs/website/MODULE_API.md §1.1).
|
||||
//
|
||||
// Bump minor when a member is ADDED to ctx or a new register* call appears;
|
||||
// major when one is removed, its signature changes, or its behaviour changes
|
||||
// without a signature change. A core-internal refactor behind an unchanged
|
||||
// member is not a bump.
|
||||
//
|
||||
// Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and
|
||||
// has nothing to say about a website module) and from any module's own version.
|
||||
|
||||
const MODULE_API_VERSION = '1.0.0'
|
||||
|
||||
module.exports = { MODULE_API_VERSION }
|
||||
@@ -1,3 +1,5 @@
|
||||
const fs = require('fs')
|
||||
|
||||
const posts = require('../../../model/posts/posts.model')
|
||||
const wiki = require('../../../model/wiki/wiki.model')
|
||||
const settings = require('../../../model/settings/settings.model')
|
||||
@@ -9,6 +11,11 @@ const announceJobs = require('../../../model/announceJobs/announceJobs.model')
|
||||
const newsGump = require('../../../utils/newsGump')
|
||||
const pushDispatch = require('../../../utils/pushDispatch')
|
||||
const { cleanBody } = require('../../../utils/sanitizeHtml')
|
||||
const { parseJsonSetting } = require('../../../utils/settingsJson')
|
||||
const { validateThemeVisual } = require('../../../utils/themeResolve')
|
||||
const { validateBrandAssets, resolveBrandAssets } = require('../../../utils/brandAssets')
|
||||
const { validateNavOverrides, resolveNavOverrides, NAV_KEYS } = require('../../../utils/navOverrides')
|
||||
const htmlShell = require('../../../utils/htmlShell')
|
||||
|
||||
const log = require('../../../utils/logger')('admin')
|
||||
|
||||
@@ -529,8 +536,61 @@ async function updateSettings(req, res) {
|
||||
if (typeof updates.homepage_teaser === 'string') {
|
||||
updates.homepage_teaser = cleanBody(updates.homepage_teaser)
|
||||
}
|
||||
// theme_visual is JSON whose values become CSS custom properties, so every
|
||||
// one has to come from the closed sets in config/themePresets.js. The read
|
||||
// path drops anything invalid anyway (THEMING_AND_NAV.md §4.4), but silently
|
||||
// storing a value that will never apply is a bad admin experience — reject it
|
||||
// with the offending field named instead. Accepts an object or the stringified
|
||||
// form, and stores it stringified either way, since settings.value is TEXT.
|
||||
if ('theme_visual' in updates) {
|
||||
const raw = updates.theme_visual
|
||||
const parsed = typeof raw === 'string' ? parseJsonSetting(raw) : raw
|
||||
if (typeof raw === 'string' && parsed === null) {
|
||||
return res.status(400).json({ message: 'theme_visual must be a JSON object' })
|
||||
}
|
||||
const check = validateThemeVisual(parsed)
|
||||
if (!check.ok) return res.status(400).json({ message: check.message })
|
||||
updates.theme_visual = JSON.stringify(parsed)
|
||||
}
|
||||
// brand_assets holds the only settings values written straight into HTML the
|
||||
// browser then fetches (an <img src>, a <link rel="icon">, an og:image), so
|
||||
// the accepted shape is narrow — see utils/brandAssets.js. Cleared slots are
|
||||
// dropped rather than stored as null, keeping "a field is absent" the single
|
||||
// meaning of "falls back to BRAND_* env".
|
||||
if ('brand_assets' in updates) {
|
||||
const raw = updates.brand_assets
|
||||
const parsed = typeof raw === 'string' ? parseJsonSetting(raw) : raw
|
||||
if (typeof raw === 'string' && parsed === null) {
|
||||
return res.status(400).json({ message: 'brand_assets must be a JSON object' })
|
||||
}
|
||||
const check = validateBrandAssets(parsed)
|
||||
if (!check.ok) return res.status(400).json({ message: check.message })
|
||||
updates.brand_assets = JSON.stringify(resolveBrandAssets(parsed))
|
||||
}
|
||||
// The three nav rows are JSON too, and without this they would reach
|
||||
// settingsDb.set as objects and be stored as the string "[object Object]".
|
||||
// Shape only — whether a key names a route the nav actually declares is the
|
||||
// client's question, and utils/navOverrides.js says why. Resolved on the way
|
||||
// in so the stored row carries no dead fields, and so `hidden` can never land
|
||||
// on the nav editor's own row.
|
||||
for (const key of NAV_KEYS) {
|
||||
if (!(key in updates)) continue
|
||||
const raw = updates[key]
|
||||
const parsed = typeof raw === 'string' ? parseJsonSetting(raw) : raw
|
||||
if (typeof raw === 'string' && parsed === null) {
|
||||
return res.status(400).json({ message: `${key} must be a JSON object` })
|
||||
}
|
||||
const check = validateNavOverrides(parsed, key)
|
||||
if (!check.ok) return res.status(400).json({ message: check.message })
|
||||
updates[key] = JSON.stringify(resolveNavOverrides(parsed, key))
|
||||
}
|
||||
try {
|
||||
await settings.setMany(updates, req.user.id)
|
||||
// The HTML shell is templated from brand_assets and theme_visual, and is
|
||||
// cached per process (utils/htmlShell.js) — a write that can change it has
|
||||
// to say so, or the favicon an admin just uploaded appears only after the
|
||||
// cache's TTL.
|
||||
if ('brand_assets' in updates || 'theme_visual' in updates) htmlShell.invalidate()
|
||||
await activity.log({ req, action: 'settings.update', detail: { keys: Object.keys(updates) } })
|
||||
return res.json(await settings.getAll())
|
||||
} catch (err) {
|
||||
@@ -539,6 +599,115 @@ async function updateSettings(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Brand assets ──────────────────────────────────────────────────────
|
||||
//
|
||||
// Per-slot rules applied on top of the shared multer allowlist. The allowlist
|
||||
// itself is never widened (§9: "no second upload path with weaker validation") —
|
||||
// these only ever tighten it:
|
||||
//
|
||||
// • favicon — PNG only. .ico would mean adding a new type to MIME_EXT, and the
|
||||
// fact that the stored extension comes from that map is exactly what makes
|
||||
// the upload path safe (§4.10). Every browser this app supports takes a PNG
|
||||
// icon. Small cap: a favicon is a handful of KB.
|
||||
// • logo — a header mark next to the site title, not a page image.
|
||||
// • hero — a full-bleed background, so it keeps the shared ceiling.
|
||||
//
|
||||
// The cap is checked after multer has written the file rather than by a second
|
||||
// multer instance: one upload config, one allowlist, and the oversized file is
|
||||
// unlinked before we answer.
|
||||
const ASSET_RULES = {
|
||||
logo: { maxBytes: 1024 * 1024, mimetypes: null, label: 'Logo' },
|
||||
hero: { maxBytes: 8 * 1024 * 1024, mimetypes: null, label: 'Hero image' },
|
||||
favicon: { maxBytes: 512 * 1024, mimetypes: ['image/png'], label: 'Favicon' },
|
||||
}
|
||||
|
||||
const prettyBytes = (n) => (n >= 1024 * 1024 ? `${Math.round(n / (1024 * 1024))} MB` : `${Math.round(n / 1024)} KB`)
|
||||
|
||||
// Best-effort cleanup of a file we have decided not to keep. A failure here is
|
||||
// a stray file in /uploads, not something the caller can act on.
|
||||
async function discardUpload(file) {
|
||||
try {
|
||||
await fs.promises.unlink(file.path)
|
||||
} catch (err) {
|
||||
log.error('discardUpload', err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /admin/settings/brand-asset/:slot — upload one brand asset and point the
|
||||
* brand_assets row at it in the same call.
|
||||
*
|
||||
* One call rather than "upload, then PUT the settings row": a half-completed
|
||||
* save would otherwise leave a file in /uploads that nothing references, and the
|
||||
* per-slot rules above need the slot at upload time anyway. Admin-only, matching
|
||||
* the gate on the settings it writes — POST /admin/uploads is reachable by
|
||||
* editors, who have no business changing the site's identity.
|
||||
*/
|
||||
async function uploadBrandAsset(req, res) {
|
||||
const { slot } = req.params
|
||||
const rules = ASSET_RULES[slot]
|
||||
if (!rules) {
|
||||
if (req.file) await discardUpload(req.file)
|
||||
return res.status(400).json({ message: `Unknown brand asset '${slot}'` })
|
||||
}
|
||||
if (!req.file) return res.status(400).json({ message: 'No file uploaded' })
|
||||
if (rules.mimetypes && !rules.mimetypes.includes(req.file.mimetype)) {
|
||||
await discardUpload(req.file)
|
||||
return res.status(400).json({ message: `${rules.label} must be a PNG image` })
|
||||
}
|
||||
if (req.file.size > rules.maxBytes) {
|
||||
await discardUpload(req.file)
|
||||
return res.status(400).json({ message: `${rules.label} must be ${prettyBytes(rules.maxBytes)} or smaller` })
|
||||
}
|
||||
|
||||
const url = `/uploads/${req.file.filename}`
|
||||
try {
|
||||
// Read-modify-write the row: uploading a logo must not clear a hero the
|
||||
// admin set earlier (§6.3). Resolved on the way in, so a hand-edited row
|
||||
// with one bad slot does not block setting another.
|
||||
const current = resolveBrandAssets(parseJsonSetting(await settings.get('brand_assets')))
|
||||
const next = { ...current, [slot]: url }
|
||||
await settings.set('brand_assets', JSON.stringify(next), req.user.id)
|
||||
htmlShell.invalidate()
|
||||
await activity.log({ req, action: 'settings.brandAsset', detail: { slot, url } })
|
||||
return res.status(201).json({ url, brand_assets: next })
|
||||
} catch (err) {
|
||||
log.error('uploadBrandAsset', err)
|
||||
// The row is the point of the call; a stored file nothing points at is
|
||||
// litter, so it goes back out with the error.
|
||||
await discardUpload(req.file)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// Delete one settings row — the "reset to defaults" primitive.
|
||||
//
|
||||
// For the theming/nav keys, defaults live in BRAND_* env, theme.css and the
|
||||
// hardcoded NAV arrays; the *absence* of the row is what selects them
|
||||
// (docs/website/THEMING_AND_NAV.md §2). Resetting therefore has to delete, not
|
||||
// store a copy of the defaults, or the next change to a default would not reach
|
||||
// an instance that had ever pressed reset.
|
||||
//
|
||||
// The key allowlist is the point of the route: an unrestricted DELETE would let
|
||||
// a stray request drop site_mode or the uo-link config, where absence means
|
||||
// something else entirely. Deleting a key that is not set succeeds — reset is
|
||||
// idempotent and the UI should not have to know whether a row exists.
|
||||
async function deleteSetting(req, res) {
|
||||
const { key } = req.params
|
||||
if (!settings.DELETABLE_KEYS.includes(key)) {
|
||||
return res.status(400).json({ message: 'Setting is not resettable' })
|
||||
}
|
||||
try {
|
||||
await settings.remove(key)
|
||||
if (key === 'brand_assets' || key === 'theme_visual') htmlShell.invalidate()
|
||||
await activity.log({ req, action: 'settings.reset', detail: { key } })
|
||||
return res.json({ message: 'Setting reset to default' })
|
||||
} catch (err) {
|
||||
log.error('deleteSetting', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Activity log ──────────────────────────────────────────────────────
|
||||
async function listActivity(req, res) {
|
||||
const limit = Math.min(Number(req.query.limit) || 50, 200)
|
||||
@@ -559,6 +728,23 @@ async function listUsers(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/users/:id — the sanitized user (so the detail page is refresh-safe).
|
||||
//
|
||||
// Lived in usersShard.controller.js until PR 4, purely because the detail page it
|
||||
// backs is mostly shard panels — MODULE_SYSTEM.md §1.9 called that out as core
|
||||
// semantics that ended up in the UO controller by proximity. Reading a user is
|
||||
// core's, and it stays here when the shard panels leave.
|
||||
async function getUser(req, res) {
|
||||
try {
|
||||
const user = await users.getById(Number(req.params.id))
|
||||
if (!user) return res.status(404).json({ message: 'Not found' })
|
||||
return res.json(user)
|
||||
} catch (err) {
|
||||
log.error('getUser', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
async function createUser(req, res) {
|
||||
try {
|
||||
if (await users.getRawByUsername(req.body.username)) {
|
||||
@@ -764,8 +950,12 @@ module.exports = {
|
||||
deleteWikiCategory,
|
||||
getSettings,
|
||||
updateSettings,
|
||||
deleteSetting,
|
||||
uploadBrandAsset,
|
||||
ASSET_RULES,
|
||||
listActivity,
|
||||
listUsers,
|
||||
getUser,
|
||||
createUser,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Admin · Posts — news, five-on-friday, newsletter and screenshot posts, plus
|
||||
// the announcement pipeline (town crier + Discord) status and retry.
|
||||
// the announcement pipeline status and retry.
|
||||
//
|
||||
// Mounted at /api/v1/admin/posts by admin/index.js, which already applied
|
||||
// `noindex, isLoggedIn, staffOnly`. No extra gate: managing content is the
|
||||
@@ -13,6 +13,7 @@ const { body, param } = require('express-validator')
|
||||
const ctrl = require('./admin.controller')
|
||||
const { upload } = require('./imageUpload')
|
||||
const validate = require('../../../middleware/validate')
|
||||
const registries = require('../../../modules/registries')
|
||||
|
||||
const postsRouter = express.Router()
|
||||
|
||||
@@ -124,14 +125,17 @@ postsRouter.get(
|
||||
postsRouter.post(
|
||||
'/:id/announce/retry',
|
||||
// #swagger.tags = ['Admin · Posts']
|
||||
// #swagger.summary = 'Retry one announcement delivery leg (town crier or Discord)'
|
||||
// #swagger.summary = 'Retry one announcement delivery leg'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Post id.' }
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { leg: { type: "string", enum: ["towncrier", "discord"] } }, required: ["leg"] } } } } */
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { leg: { type: "string", description: "A registered delivery leg id, as returned by GET /announce." } }, required: ["leg"] } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Updated announce job', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No announcement job for this post', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
body('leg').isIn(['towncrier', 'discord']),
|
||||
// The allowlist is the REGISTERED leg set, read per request rather than
|
||||
// captured at require time: this file is required while app.js is being built,
|
||||
// before registerCore() and modules.load() have run (MODULE_SYSTEM.md §1.8).
|
||||
body('leg').custom((leg) => registries.announceLeg(leg) != null).withMessage('unknown announce leg'),
|
||||
validate,
|
||||
ctrl.retryAnnounceLeg,
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
const express = require('express')
|
||||
|
||||
const ctrl = require('./admin.controller')
|
||||
const { upload } = require('./imageUpload')
|
||||
const { requireRole } = require('../../../utils/auth')
|
||||
|
||||
const settingsRouter = express.Router()
|
||||
@@ -33,6 +34,7 @@ settingsRouter.put(
|
||||
// #swagger.tags = ['Admin · Settings']
|
||||
// #swagger.summary = 'Update site settings (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.description = 'Writes the given keys. The JSON-valued theming keys (theme_visual, brand_assets, nav_public, nav_admin, nav_player) accept an object or its stringified form, are validated strictly with the offending field named in the 400, and are stored stringified with unusable fields dropped. Nav overrides key coded entries by their existing route and carry only label/order/hidden/group/section; whether a key names a route the nav declares is settled client-side at merge time. nav_public may additionally carry admin-created dropdown `sections` and admin-authored `links` — the only place an arbitrary path may be named, and therefore restricted to same-origin paths (no scheme, no protocol-relative host). Sections and links are dropped for the other two navs, which cannot render them.'
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", additionalProperties: true, description: "An object of key/value settings." } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Updated settings', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Body must be an object of key/value settings', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
@@ -41,5 +43,42 @@ settingsRouter.put(
|
||||
adminOnly,
|
||||
ctrl.updateSettings,
|
||||
)
|
||||
// Upload one brand asset (logo/hero/favicon) and point brand_assets at it in the
|
||||
// same call — see the controller for why it is one call and not "upload, then
|
||||
// PUT". Uses the shared multer config (one upload directory, one mimetype
|
||||
// allowlist); the per-slot PNG rule and size caps are applied in the handler.
|
||||
settingsRouter.post(
|
||||
'/brand-asset/:slot',
|
||||
// #swagger.tags = ['Admin · Settings']
|
||||
// #swagger.summary = 'Upload a brand asset and set it as the override (admin only)'
|
||||
// #swagger.description = 'Stores the image and writes the brand_assets settings row in one call, so an upload never leaves an unreferenced file. Favicons must be PNG (max 512 KB); logos max 1 MB; heroes max 8 MB. Absent slots keep falling back to the BRAND_* env defaults — uploading a logo does not clear a hero.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.parameters['slot'] = { in: 'path', required: true, description: 'Which asset to replace', schema: { type: 'string', enum: ['logo', 'hero', 'favicon'] } } */
|
||||
/* #swagger.requestBody = { required: true, content: { "multipart/form-data": { schema: { type: "object", properties: { image: { type: "string", format: "binary" } } } } } } */
|
||||
/* #swagger.responses[201] = { description: 'Stored file URL and the updated overrides', content: { "application/json": { schema: { type: "object", properties: { url: { type: "string", example: "/uploads/1712345678901-ab12cd34.png" }, brand_assets: { type: "object", properties: { logo: { type: "string" }, hero: { type: "string" }, favicon: { type: "string" } } } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'No file, unknown slot, disallowed type, or over the slot size cap', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
upload.single('image'),
|
||||
ctrl.uploadBrandAsset,
|
||||
)
|
||||
// Reset one setting to its default by deleting the row. Only the keys whose
|
||||
// default lives outside the store (theming, nav, hero draft) are deletable —
|
||||
// the controller holds the allowlist.
|
||||
settingsRouter.delete(
|
||||
'/:key',
|
||||
// #swagger.tags = ['Admin · Settings']
|
||||
// #swagger.summary = 'Reset one setting to its default (admin only)'
|
||||
// #swagger.description = 'Deletes the settings row so the surface falls back to its BRAND_* env / theme.css / hardcoded default. Restricted to the resettable keys (theme_visual, brand_assets, nav_public, nav_admin, nav_player, hero_layout_draft). Idempotent: resetting a key that was never set succeeds.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.parameters['key'] = { in: 'path', required: true, description: 'Settings key to reset', schema: { type: 'string' } } */
|
||||
/* #swagger.responses[200] = { description: 'Setting reset', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Setting is not resettable', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
ctrl.deleteSetting,
|
||||
)
|
||||
|
||||
module.exports = settingsRouter
|
||||
|
||||
@@ -24,6 +24,8 @@ const { body, param } = require('express-validator')
|
||||
|
||||
const shardOps = require('./shardOps.controller')
|
||||
const shardVisibility = require('./shardVisibility.controller')
|
||||
const shardAtlas = require('./shardAtlas.controller')
|
||||
const shardClilocs = require('./shardClilocs.controller')
|
||||
const selfShard = require('../player/shard.controller')
|
||||
const { requireRole } = require('../../../utils/auth')
|
||||
const validate = require('../../../middleware/validate')
|
||||
@@ -235,6 +237,123 @@ shardRouter.get(
|
||||
shardOps.listHouses,
|
||||
)
|
||||
|
||||
// ── Spawn atlas (admin only) ──────────────────────────────────────────
|
||||
// Operating the atlas import. Admin-only rather than moderator: it reads a path
|
||||
// on the server's filesystem and replaces every atlas table, which is closer to
|
||||
// a deploy action than to moderation.
|
||||
//
|
||||
// These routes sit under /admin/shard even though the public ones deliberately
|
||||
// do NOT sit under /public/shard. That is not an inconsistency: the public split
|
||||
// says "this data does not come from the sidecar", while the admin panel is
|
||||
// simply part of shard administration and belongs beside the rest of it.
|
||||
shardRouter.get(
|
||||
'/atlas',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Spawn atlas status: path, drift, counts, pending review (admin only)'
|
||||
// #swagger.description = 'Where the ServUO tree is, whether it can be read, whether its source files have drifted from the loaded atlas, and any refresh staged for approval. The public /atlas/meta route reports the game world only; the filesystem detail is here.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Atlas status', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasStatus" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
shardAtlas.getStatus,
|
||||
)
|
||||
shardRouter.post(
|
||||
'/atlas/import',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Re-import the spawn atlas from the ServUO tree (admin only)'
|
||||
// #swagger.description = 'Applies a map change without a restart. `force` reimports even when the source hashes match what is loaded. A refresh that would REMOVE a facet is still staged for approval rather than applied — that decision is never taken implicitly. An unreadable tree answers 200 with status "unavailable" rather than 500: the refresh contract reports outcomes instead of throwing, and the admin needs to be told what is wrong with the path.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { type: "object", properties: { force: { type: "boolean", description: "Reimport even if the tree is unchanged." } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'What happened', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasRefreshResult" } } } } */
|
||||
adminOnly,
|
||||
body('force').optional().isBoolean(),
|
||||
validate,
|
||||
shardAtlas.importAtlas,
|
||||
)
|
||||
shardRouter.post(
|
||||
'/atlas/approve',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Approve a staged atlas refresh that removes a facet (admin only)'
|
||||
// #swagger.description = 'Re-parses the tree and applies it, facet loss included. Only the decision was stored, never the parsed world, so what lands matches the tree at approval time — an operator who has since fixed a half-copied mount gets the corrected import.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'What happened', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasRefreshResult" } } } } */
|
||||
adminOnly,
|
||||
shardAtlas.approve,
|
||||
)
|
||||
shardRouter.post(
|
||||
'/atlas/reject',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Reject a staged atlas refresh (admin only)'
|
||||
// #swagger.description = 'Keeps the current atlas and remembers the decision against those exact source hashes, so a declined refresh does not re-prompt on every restart. Changing the tree asks again.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Rejected', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasRefreshResult" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Nothing is awaiting review', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
shardAtlas.reject,
|
||||
)
|
||||
shardRouter.put(
|
||||
'/atlas/path',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Set the ServUO tree the atlas reads from (admin only)'
|
||||
// #swagger.description = 'Persisted as a setting, which wins over the SERVUO_PATH deploy default so the mount can move without a redeploy. Blank clears it and the atlas is simply skipped on the next boot. Deliberately does not import as a side effect — the response carries the refreshed status so the panel can offer that as the next step.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["path"], properties: { path: { type: "string", description: "Absolute path to the ServUO server root. Blank disables the atlas." } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Atlas status after the change', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasStatus" } } } } */
|
||||
adminOnly,
|
||||
body('path').isString().isLength({ max: 512 }),
|
||||
validate,
|
||||
shardAtlas.setPath,
|
||||
)
|
||||
|
||||
// ── Cliloc table (admin only) ─────────────────────────────────────────────
|
||||
// UO's id → display-string map, converted once by the operator from their own
|
||||
// client (docs/website/CLILOCS.md). Sits beside the atlas for the same reason:
|
||||
// it is static content derived from operator-supplied files rather than anything
|
||||
// the sidecar sends, and operating it is shard administration.
|
||||
//
|
||||
// There is deliberately NO public counterpart. The table is never served as a
|
||||
// table — 123k rows would dwarf any page that used it, and the Android client
|
||||
// consumes the same already-resolved JSON. Names are applied server-side to the
|
||||
// responses that need them.
|
||||
shardRouter.get(
|
||||
'/clilocs',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Cliloc table status: sources, drift, entry count (admin only)'
|
||||
// #swagger.description = 'Where the cliloc sources are, whether they can be read, how many entries are loaded, and whether the files on disk have drifted from them. The table is built from a SET of sources — the converted client table plus every operator-maintained overlay under `custom/`, which is how shard-added and shard-edited items get names. `missingSources` lists any source that was loaded before and is now gone; an import refuses that without `approve`. A shard with nothing configured is a supported state — item names simply render as ids.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Cliloc status', content: { "application/json": { schema: { $ref: "#/components/schemas/ClilocStatus" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
shardClilocs.getStatus,
|
||||
)
|
||||
shardRouter.post(
|
||||
'/clilocs/import',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Re-import the cliloc table from its source files (admin only)'
|
||||
// #swagger.description = 'Applies a client patch, or a change to the shard\'s own overlay files, without a restart. `force` reimports even when the source hashes match what is loaded. `approve` accepts a refresh in which a previously-loaded source has VANISHED — refused by default, because an unmounted volume and a deliberate deletion are indistinguishable from the server, and the wrong guess silently drops every name that file contributed. A missing path — or the common mistake of pointing at the client\'s own COMPRESSED Cliloc.enu — answers 200 with status "unavailable" and the reason, rather than 500: the refresh contract reports outcomes instead of throwing, and the admin needs to be told which file to convert.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { type: "object", properties: { force: { type: "boolean", description: "Reimport even if the sources are unchanged." }, approve: { type: "boolean", description: "Accept a refresh in which a previously-loaded source has vanished." } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'What happened', content: { "application/json": { schema: { $ref: "#/components/schemas/ClilocRefreshResult" } } } } */
|
||||
adminOnly,
|
||||
body('force').optional().isBoolean(),
|
||||
body('approve').optional().isBoolean(),
|
||||
validate,
|
||||
shardClilocs.importClilocs,
|
||||
)
|
||||
shardRouter.put(
|
||||
'/clilocs/path',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Set the cliloc source the site reads from (admin only)'
|
||||
// #swagger.description = 'Accepts either the converted base file itself or a directory to search. Overlays are read from a `custom/` directory beside it either way — pointing at a file does not forfeit them. Persisted as a setting, which wins over the UO_CLIENT_PATH deploy default so the mount can move without a redeploy. Blank clears it and resolution is skipped on the next boot. Deliberately does not import as a side effect — the response carries the refreshed status so the panel can offer that as the next step.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["path"], properties: { path: { type: "string", description: "Path to the converted cliloc file, or a directory containing one. Blank disables resolution." } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Cliloc status after the change', content: { "application/json": { schema: { $ref: "#/components/schemas/ClilocStatus" } } } } */
|
||||
adminOnly,
|
||||
body('path').isString().isLength({ max: 512 }),
|
||||
validate,
|
||||
shardClilocs.setPath,
|
||||
)
|
||||
|
||||
// ── Feature visibility (admin only) ───────────────────────────────────
|
||||
// Who can see which shard surface, and which sensitive fields within it. This
|
||||
// decides what ANONYMOUS visitors get, so it sits above the moderator tier.
|
||||
|
||||
117
server/src/router/v1/admin/shardAtlas.controller.js
Normal file
117
server/src/router/v1/admin/shardAtlas.controller.js
Normal file
@@ -0,0 +1,117 @@
|
||||
// ── Admin · Spawn atlas ────────────────────────────────────────────────────
|
||||
//
|
||||
// Operating the atlas import: where the ServUO tree is, whether it has drifted
|
||||
// from what is loaded, and the approve/reject decision for a refresh that would
|
||||
// remove a facet (docs/website/SPAWN_ATLAS.md).
|
||||
//
|
||||
// The policy lives in the model. This controller does three things and no more:
|
||||
// it validates input, it maps a refresh RESULT onto an HTTP status, and it
|
||||
// records the action in the admin activity log.
|
||||
//
|
||||
// **A refresh result is not an exception.** `shardAtlas.refresh()` reports
|
||||
// `unavailable` / `failed` / `needsReview` rather than throwing, because the boot
|
||||
// path must never be stopped by a bad tree. That contract is preserved here: an
|
||||
// unreadable mount is a 200 carrying `status: 'unavailable'`, not a 500. The
|
||||
// admin needs to be told what is wrong with their path, and a 500 says only
|
||||
// "something broke".
|
||||
|
||||
const atlas = require('../../../model/shardAtlas/shardAtlas.model')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
|
||||
const log = require('../../../utils/logger')('admin-shard-atlas')
|
||||
|
||||
// GET /admin/shard/atlas — what is loaded, what the tree looks like, what is
|
||||
// staged. Unlike the public /atlas/meta route this DOES carry the filesystem
|
||||
// path and the drift flag: that is the whole point of the panel.
|
||||
async function getStatus(req, res) {
|
||||
try {
|
||||
return res.json(await atlas.status())
|
||||
} catch (err) {
|
||||
log.error('getStatus', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /admin/shard/atlas/import — apply a map change without a restart.
|
||||
//
|
||||
// `force` reimports even when the source hashes match what is loaded (the escape
|
||||
// hatch for "the database is wrong but the tree is not"). Facet loss is still
|
||||
// staged rather than applied — approving is a separate, explicit act.
|
||||
async function importAtlas(req, res) {
|
||||
try {
|
||||
const force = !!req.body?.force
|
||||
const result = await atlas.refresh({ force })
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'shard.atlas.import',
|
||||
detail: { force, status: result.status, counts: result.counts ?? null },
|
||||
})
|
||||
return res.json(result)
|
||||
} catch (err) {
|
||||
log.error('importAtlas', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /admin/shard/atlas/approve — apply a staged refresh, facet loss and all.
|
||||
//
|
||||
// Re-parses the tree rather than applying something captured at boot: only the
|
||||
// DECISION was stored, so what lands matches the tree as it is now. If the
|
||||
// operator has since fixed a half-copied mount, the approved import is simply
|
||||
// the corrected one — which is the desired outcome, not a surprise.
|
||||
async function approve(req, res) {
|
||||
try {
|
||||
const result = await atlas.approvePending()
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'shard.atlas.approve',
|
||||
detail: { status: result.status, removed: result.removedFacets ?? null },
|
||||
})
|
||||
return res.json(result)
|
||||
} catch (err) {
|
||||
log.error('approveAtlas', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /admin/shard/atlas/reject — keep the current atlas and remember the
|
||||
// decision against those exact source hashes, so a declined refresh does not
|
||||
// re-prompt on every restart. Changing the tree asks again.
|
||||
async function reject(req, res) {
|
||||
try {
|
||||
const result = await atlas.rejectPending()
|
||||
if (result.status === 'none') {
|
||||
return res.status(404).json({ message: 'No refresh is awaiting review.' })
|
||||
}
|
||||
await activity.log({ req, action: 'shard.atlas.reject', detail: {} })
|
||||
return res.json(result)
|
||||
} catch (err) {
|
||||
log.error('rejectAtlas', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /admin/shard/atlas/path — point the atlas at a different ServUO tree.
|
||||
//
|
||||
// Persisted as a setting, which wins over the SERVUO_PATH env default so an
|
||||
// operator can move the mount without a redeploy. Blank clears it, which turns
|
||||
// the atlas off (boot skips, the loaded atlas keeps serving) — that is a
|
||||
// legitimate thing to want, so it is allowed rather than validated away.
|
||||
//
|
||||
// Deliberately does NOT import as a side effect: changing where the atlas reads
|
||||
// from and reloading it are separate decisions, and an operator fixing a typo
|
||||
// should not have a multi-thousand-row replace happen under them. The response
|
||||
// carries the refreshed status so the panel can offer the import immediately.
|
||||
async function setPath(req, res) {
|
||||
try {
|
||||
const value = String(req.body?.path ?? '').trim()
|
||||
await atlas.setServuoPath(value, req.user?.id ?? null)
|
||||
await activity.log({ req, action: 'shard.atlas.path', detail: { path: value } })
|
||||
return res.json(await atlas.status())
|
||||
} catch (err) {
|
||||
log.error('setAtlasPath', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getStatus, importAtlas, approve, reject, setPath }
|
||||
106
server/src/router/v1/admin/shardClilocs.controller.js
Normal file
106
server/src/router/v1/admin/shardClilocs.controller.js
Normal file
@@ -0,0 +1,106 @@
|
||||
// ── Admin · Cliloc table ───────────────────────────────────────────────────
|
||||
//
|
||||
// Operating the cliloc import: where the converted cliloc file is, whether it
|
||||
// has drifted from what is loaded, and a forced reimport after a client patch
|
||||
// (docs/website/CLILOCS.md).
|
||||
//
|
||||
// The policy lives in the model. This controller does three things and no more:
|
||||
// it validates input, it maps a refresh RESULT onto an HTTP status, and it
|
||||
// records the action in the admin activity log.
|
||||
//
|
||||
// **A refresh result is not an exception.** `shardClilocs.refresh()` reports
|
||||
// `unavailable` / `failed` rather than throwing, because the boot path must never
|
||||
// be stopped by a bad file. That contract is preserved here: a missing file, or
|
||||
// the single most likely operator mistake — pointing at the client's own
|
||||
// COMPRESSED `Cliloc.enu` — is a 200 carrying `status: 'unavailable'` and the
|
||||
// reason, not a 500. A 500 would say only "something broke"; the operator needs
|
||||
// to be told which file to convert.
|
||||
|
||||
const clilocs = require('../../../model/shardClilocs/shardClilocs.model')
|
||||
const market = require('../../../model/shardMarket/shardMarket.model')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
|
||||
const log = require('../../../utils/logger')('admin-shard-clilocs')
|
||||
|
||||
// GET /admin/shard/clilocs — what is loaded, what the file looks like, whether
|
||||
// they disagree. There is no public counterpart: the cliloc table is never
|
||||
// served as a table, only applied to names the site already returns.
|
||||
async function getStatus(req, res) {
|
||||
try {
|
||||
return res.json(await clilocs.status())
|
||||
} catch (err) {
|
||||
log.error('getStatus', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /admin/shard/clilocs/import — reload after a client patch or a change to
|
||||
// the shard's own overlay files, without a restart.
|
||||
//
|
||||
// `force` reimports even when the source hashes match what is loaded (the escape
|
||||
// hatch for "the database is wrong but the files are not").
|
||||
//
|
||||
// `approve` accepts a refresh in which a previously-loaded source has VANISHED.
|
||||
// That is refused by default because an unmounted volume and a deliberate
|
||||
// deletion look identical from the server — the lighter cousin of the atlas's
|
||||
// approve/reject flow, and the reason it can be a flag here rather than a
|
||||
// pending table is that nothing is stored to approve: the import re-reads the
|
||||
// files at approval time by construction.
|
||||
async function importClilocs(req, res) {
|
||||
try {
|
||||
const force = !!req.body?.force
|
||||
const approve = !!req.body?.approve
|
||||
const result = await clilocs.refresh({ force, approve })
|
||||
|
||||
// The marketplace denormalizes resolved item names into
|
||||
// shard_vendor_items.display_name, and the shard's market sweep will NOT
|
||||
// re-send an unchanged shop just because the site learned what its items are
|
||||
// called — so without this pass, an operator who imports clilocs after the
|
||||
// first sweep keeps seeing item ids until every shop happens to change.
|
||||
// Awaited (rather than fired and forgotten) so the panel's "imported" is
|
||||
// honest about the names being live; the pass is a bounded walk of one table
|
||||
// and never throws.
|
||||
if (result.status === 'imported') await market.refreshDisplayNames()
|
||||
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'shard.clilocs.import',
|
||||
detail: {
|
||||
force,
|
||||
approve,
|
||||
status: result.status,
|
||||
count: result.count ?? null,
|
||||
missingSources: result.missingSources ?? result.acceptedMissing ?? null,
|
||||
},
|
||||
})
|
||||
return res.json(result)
|
||||
} catch (err) {
|
||||
log.error('importClilocs', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /admin/shard/clilocs/path — point the site at a different cliloc file.
|
||||
//
|
||||
// Persisted as a setting, which wins over the UO_CLIENT_PATH env default so an
|
||||
// operator can move the mount without a redeploy. Blank clears it, which turns
|
||||
// resolution off (boot skips, the loaded table keeps serving) — a legitimate
|
||||
// thing to want, so it is allowed rather than validated away.
|
||||
//
|
||||
// Deliberately does NOT import as a side effect, for the same reason the atlas
|
||||
// path does not: changing where the table reads from and reloading it are
|
||||
// separate decisions. The response carries the refreshed status so the panel can
|
||||
// offer the import immediately.
|
||||
async function setPath(req, res) {
|
||||
try {
|
||||
const value = String(req.body?.path ?? '').trim()
|
||||
await clilocs.setClientPath(value, req.user?.id ?? null)
|
||||
await activity.log({ req, action: 'shard.clilocs.path', detail: { path: value } })
|
||||
return res.json(await clilocs.status())
|
||||
} catch (err) {
|
||||
log.error('setClilocPath', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getStatus, importClilocs, setPath }
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user