20 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two supporting fixes:

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

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

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

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

Four decisions, all recorded in MODULE_API.md 2.5 and 4.4:

- The loader classifies its failures by 4.3 step, so failure_stage says where a
  module broke instead of being a column nothing ever filled. The four steps
  readManifest covers in one pass label themselves; the rest are inferred from
  how far load() had got, and an unlabelled throw is recorded against the step
  that was running rather than guessed at.
- A row whose directory is gone is marked startup_failed rather than left
  claiming `enabled` -- the boot reset has just moved it there, and a row
  claiming to be enabled for a module that is not on the volume is the one state
  that is simply untrue. An uninstall leaves `disabled`, which the reset never
  touches, so this catches only a hand-deleted directory.
- Core's eight UO boot call sites stay in server.js until Phase 3. Unlike a
  registered announce leg, a boot call site already has somewhere to live, so
  moving it now would be extraction done early in a phase whose exit criterion
  is that nothing changes.
- onBoot gets no timeout. Shutdown races a SIGKILL and boot does not, and a slow
  onBoot delaying the listener is the contract's promise to a module that must
  warm up before it serves.

The operator's switch wins over everything: a disabled module is guarded, not
booted, and does not have its failure re-recorded, or an outcome would silently
switch it back on next boot. Every database write in the reconcile is
individually caught -- a row that will not update is worse reporting, never a
failed boot.

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

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

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

Four decisions, all the recommended option:

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

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

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

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

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

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

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

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

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

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

Verification:

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

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

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

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

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

registerExtension/NotificationStreams/AnnounceLeg and onBoot/onShutdown
throw "not available until phase 2 PR 4/5" rather than no-op; an
accepting stub would let a module believe it had registered something.
No schema replay, no boot dispatch, no installed_modules reconcile --
those are PRs 3 and 5, and until PR 5 a record's state is in memory only.

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

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

The five states of 2.4 live in one `state` column: installed -> enabled ->
started, with disabled and startup_failed as the recoverable ones. The row is
a record of what happened, never the source of truth for what is mounted --
the loader scans the filesystem at require time, before the database is
reachable (MODULE_API.md 4.1), which is what keeps routes.manifest.json
generatable against a dead database.

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 06:35:57 -05:00
120 changed files with 7584 additions and 4641 deletions

View File

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

7
.gitignore vendored
View File

@@ -21,6 +21,13 @@ uploads/
server/logs/ server/logs/
logs/ logs/
# Installed modules (docs/website/MODULE_SYSTEM.md). Core ships no module, so
# anything here is an operator's install or a developer's scratch copy. The
# directory itself IS tracked, via its README: docker-compose.yml bind-mounts it,
# and a missing bind-mount source is recreated by Docker as root-owned.
modules/*
!modules/README.md
# Operator-supplied spawn atlas artwork. Creature art is never committed: sprites # 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, # 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 # not ours to redistribute. The images live under server/uploads/atlas/, already

View File

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

View File

@@ -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" }` - Health check: `GET http://localhost:3000/api/health` → `{ "status": "ok" }`
- Logs: `docker compose logs -f app` (and `./logs/app.log` on the host) - Logs: `docker compose logs -f app` (and `./logs/app.log` on the host)
- Stop: `docker compose down` (add `-v` to also wipe the database + uploads volumes) - Stop: `docker compose down` (add `-v` to also wipe the database + uploads volumes)
- Modules: installed into `./modules` on the host (bind-mounted to `/app/modules`), never baked into
the image — an operator adds one to a pull-only deployment without building anything. Adding or
removing one takes a `docker compose restart app`; the scan is synchronous at startup. See
[`modules/README.md`](modules/README.md).
**Build the images locally instead of pulling** (offline, or to test an unmerged change) — overlay **Build the images locally instead of pulling** (offline, or to test an unmerged change) — overlay
the dev file, which adds `build:` back: the dev file, which adds `build:` back:
@@ -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 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 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` `/api/**` and `/.well-known/**` plus the internal listener. The SPA catch-all, `/uploads`, `/brand`
are filesystem-conditional static mounts, not API contract, so they are excluded and the output does and installed modules' `/modules/<id>` chunks are filesystem-conditional static mounts, not API
not depend on whether the client has been built. 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: Two generated files, two very different meanings:
@@ -507,6 +512,7 @@ Copy `.env.example` (Compose) or `server/.env.example` (local) and fill in. **`.
| `NODE_ENV` | `production` | | | `NODE_ENV` | `production` | |
| `PORT` | `3000` | server listens on `0.0.0.0:PORT` | | `PORT` | `3000` | server listens on `0.0.0.0:PORT` |
| `UPLOAD_DIR` | `<server>/uploads` | where post images are written (`/app/uploads`, volume-mounted, in Compose) | | `UPLOAD_DIR` | `<server>/uploads` | where post images are written (`/app/uploads`, volume-mounted, in Compose) |
| `MODULES_DIR` | `<repo>/modules` | where installed modules are scanned from (`/app/modules`, bind-mounted, in Compose) |
| `DB_HOST` / `DB_PORT` | `db` / `3306` | `db` in Compose; `127.0.0.1` for local dev | | `DB_HOST` / `DB_PORT` | `db` / `3306` | `db` in Compose; `127.0.0.1` for local dev |
| `DB_NAME` / `DB_USER` / `DB_PASSWORD` | `runic_gateway` / `runic` / — | app database credentials | | `DB_NAME` / `DB_USER` / `DB_PASSWORD` | `runic_gateway` / `runic` / — | app database credentials |
| `DB_ROOT_PASSWORD` | — | MariaDB root (Compose only) | | `DB_ROOT_PASSWORD` | — | MariaDB root (Compose only) |

View File

@@ -6,6 +6,7 @@ import RequireAuth from './components/RequireAuth.jsx'
import RequirePlayer from './components/RequirePlayer.jsx' import RequirePlayer from './components/RequirePlayer.jsx'
import RoleGate from './components/RoleGate.jsx' import RoleGate from './components/RoleGate.jsx'
import { routesFor } from './modules/registry.js' import { routesFor } from './modules/registry.js'
import { ModuleFeaturesProvider } from './modules/features.jsx'
// Public // Public
import Portal from './routes/public/Portal.jsx' import Portal from './routes/public/Portal.jsx'
@@ -24,6 +25,8 @@ import Guilds from './routes/public/Guilds.jsx'
import Governors from './routes/public/Governors.jsx' import Governors from './routes/public/Governors.jsx'
import Houses from './routes/public/Houses.jsx' import Houses from './routes/public/Houses.jsx'
import Rules from './routes/public/Rules.jsx' import 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 Leaderboards from './routes/public/Leaderboards.jsx'
import Market from './routes/public/Market.jsx' import Market from './routes/public/Market.jsx'
import MarketVendor from './routes/public/MarketVendor.jsx' import MarketVendor from './routes/public/MarketVendor.jsx'
@@ -78,173 +81,198 @@ export default function App() {
return ( return (
<AuthProvider> <AuthProvider>
<SiteProvider> <SiteProvider>
<Routes> {/* Inside the auth and site contexts, because a feature provider is a
{/* Landing hero — always public, even in maintenance mode. The hero is hook that may well read either — the shard one does, indirectly, by
itself the pre-launch "coming soon" page, so it sits outside the asking an endpoint whose answer depends on the session. Outside the
MaintenanceGate and every visitor sees it regardless of auth/site mode. */} routes, so the nav in every layout is filtered by the same gate and
<Route path="/" element={<Portal />} /> 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) */} {/* 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="/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>/…` (§2.8).
Declared BEFORE the /:slug CMS catch-all: React Router ranks
static segments over dynamic ones so the order is not what saves
us, but keeping them adjacent makes the relationship visible. */}
{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>
{/* 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 />} />
{/* 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 <Route
path="appearance"
element={ element={
<RoleGate roles={['admin']}> <MaintenanceGate>
<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 /> <Outlet />
</RoleGate> </MaintenanceGate>
} }
> >
<Route index element={<Moderation />} /> <Route path="/site" element={<Website />} />
<Route path="user/:discordId" element={<ModerationUser />} /> <Route path="/site/news" element={<News />} />
<Route path="appeals" element={<Appeals />} /> <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>
<Route path="activity" element={<ActivityAdmin />} />
<Route path="bot-activity" element={<BotActivityAdmin />} /> {/* Draft-preview link (token-gated). Outside the maintenance gate so a
<Route path="discord-bot" element={<DiscordBotAdmin />} /> preview link works regardless of site mode. */}
<Route path="shard" element={<ShardAdmin />} /> <Route path="/preview/:id/:token" element={<CmsPage preview />} />
<Route path="shard-visibility" element={<ShardVisibility />} />
<Route path="shard-atlas" element={<SpawnAtlasAdmin />} /> {/* Admin */}
<Route path="/admin/login" element={<AdminLogin />} />
<Route <Route
path="shard-ops" path="/admin"
element={ element={
<RoleGate roles={['admin', 'moderator']}> <RequireAuth>
<ShardOps /> <AdminLayout />
</RoleGate> </RequireAuth>
} }
/> >
<Route <Route index element={<Dashboard />} />
path="houses" <Route path="posts" element={<PostsAdmin />} />
element={ <Route path="pages" element={<PagesAdmin />} />
<RoleGate roles={['admin', 'moderator']}> <Route path="pages/new" element={<PageBuilder />} />
<HousesAdmin /> <Route path="pages/:id" element={<PageBuilder />} />
</RoleGate> <Route path="wiki" element={<WikiAdmin />} />
} <Route path="hero" element={<HeroEditor />} />
/> {/* Theme editing writes an admin-only settings key; the route sits
<Route path="characters" element={<AdminCharacters />} /> behind the same RoleGate as the sidebar entry that reaches it,
<Route path="characters/:serial" element={<AdminCharacter />} /> and PUT/DELETE /admin/settings is admin-only server-side too. */}
<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 } that core applies as the
same RoleGate its own routes use (MODULE_API.md §3.3). */}
{routesFor('admin').map((r) => (
<Route <Route
key={r.path} path="appearance"
path={r.path} element={
element={r.gate ? <RoleGate roles={r.gate.roles}>{r.element}</RoleGate> : r.element} <RoleGate roles={['admin']}>
<AppearanceAdmin />
</RoleGate>
}
/> />
))} {/* Same reasoning as Appearance: the nav overrides are an admin-only
<Route path="*" element={<Navigate to="/admin" replace />} /> settings key, so the route carries the same RoleGate as the
</Route> 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 */} {/* Player portal */}
<Route path="/account/login" element={<PlayerLogin />} /> <Route path="/account/login" element={<PlayerLogin />} />
<Route path="/account/register" element={<PlayerRegister />} /> <Route path="/account/register" element={<PlayerRegister />} />
<Route path="/account/forgot" element={<ForgotPassword />} /> <Route path="/account/forgot" element={<ForgotPassword />} />
<Route path="/account/reset/:token" element={<ResetPassword />} /> <Route path="/account/reset/:token" element={<ResetPassword />} />
<Route path="/invite/:token" element={<AcceptInvite />} /> <Route path="/invite/:token" element={<AcceptInvite />} />
<Route <Route
element={ element={
<RequirePlayer> <RequirePlayer>
<PlayerPortalLayout /> <PlayerPortalLayout />
</RequirePlayer> </RequirePlayer>
} }
> >
<Route path="/player" element={<PlayerCharacters />} /> <Route path="/player" element={<PlayerCharacters />} />
<Route path="/player/char/:serial" element={<PlayerCharacter />} /> <Route path="/player/char/:serial" element={<PlayerCharacter />} />
<Route path="/account" element={<PlayerAccount />} /> <Route path="/account" element={<PlayerAccount />} />
<Route path="/account/appeals" element={<PlayerAppeals />} /> <Route path="/account/appeals" element={<PlayerAppeals />} />
</Route> {/* 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 />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</ModuleFeaturesProvider>
</SiteProvider> </SiteProvider>
</AuthProvider> </AuthProvider>
) )

View File

@@ -42,14 +42,15 @@ function safeParse(text) {
} }
} }
// The request PRIMITIVE, exported for installed modules (window.__rg.api — see // The request PRIMITIVE, exported for installed modules and handed to them on
// docs/website/MODULE_API.md §3.5). A module owns the paths it calls, because it // `window.__rg.api` (docs/website/MODULE_API.md §3.5). Core owns the fetch
// owns the routes at the other end; core owns only the fetch semantics — // semantics — same-origin /api/v1, cookies included, JSON in and out, ApiError
// same-origin /api/v1, cookies included, JSON in/out, ApiError on non-2xx. // on a non-2xx — and nothing above them: a module owns the paths it calls,
// because it owns the routes at the other end.
// //
// `api` below stays core's own binding surface. Its `atlas` and `shard` // The `api` object below stays core's own binding surface. Its `atlas` and
// namespaces are module bindings that only still live here because Phase 3 has // `shard` namespaces are module bindings that only still live here because
// not moved them yet. // Phase 3 has not moved them yet.
export { req as request } export { req as request }
export const api = { export const api = {

View File

@@ -4,23 +4,28 @@ import MoonDot from './MoonDot.jsx'
import BrandLogo from './BrandLogo.jsx' import BrandLogo from './BrandLogo.jsx'
import { useAuth } from '../contexts/AuthContext.jsx' import { useAuth } from '../contexts/AuthContext.jsx'
import { useSite } from '../contexts/SiteContext.jsx' import { useSite } from '../contexts/SiteContext.jsx'
import { useShardFeatures, canSee } from '../lib/useShardFeatures.js'
import NavDropdown from './NavDropdown.jsx' import NavDropdown from './NavDropdown.jsx'
import { buildPublicNav, pruneNav } from '../lib/navOverrides.js' import { buildPublicNav, pruneNav } from '../lib/navOverrides.js'
import { navFor } from '../modules/registry.js'
import { parseJsonSetting } from '../lib/settingsJson.js' import { parseJsonSetting } from '../lib/settingsJson.js'
import { withModuleNav } from '../modules/nav.js'
import { useFeatureGate } from '../modules/features.jsx'
// One consistent top nav for the whole public site. Every page gets the same // One consistent top nav for the whole public site. Every page gets the same
// main links plus an auth-aware entry on the right (Sign in / My Account / Admin). // main links plus an auth-aware entry on the right (Sign in / My Account / Admin).
// //
// Entries carrying a `feature` are shard surfaces an admin can disable or gate // Entries carrying a `feature` are surfaces an admin can disable or gate to a
// to a higher audience (Admin -> Shard Visibility). They are hidden when this // higher audience (Admin -> Shard Visibility). They are hidden when this viewer
// viewer can't reach them, so we never render a link that would 403. The gate // can't reach them, so we never render a link that would 403. The gate itself is
// itself is server-side; this is only about not advertising a dead end. // 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, // Exported because Admin -> Navigation edits this list. It stays declared here,
// with this component as its owner: the editor may only relabel, reorder and // 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). // 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 = [ export const NAV = [
{ label: 'Home', to: '/', end: true }, { label: 'Home', to: '/', end: true },
{ label: 'News', to: '/site/news' }, { label: 'News', to: '/site/news' },
@@ -34,6 +39,7 @@ export const NAV = [
{ label: 'Governors', to: '/site/governors', feature: 'governors' }, { label: 'Governors', to: '/site/governors', feature: 'governors' },
{ label: 'Houses', to: '/site/houses', feature: 'houses' }, { label: 'Houses', to: '/site/houses', feature: 'houses' },
{ label: 'Rules', to: '/site/rules', feature: 'ruleset' }, { label: 'Rules', to: '/site/rules', feature: 'ruleset' },
{ label: 'Atlas', to: '/site/atlas', feature: 'atlas' },
{ label: 'Leaderboards', to: '/site/leaderboards', feature: 'leaderboards' }, { label: 'Leaderboards', to: '/site/leaderboards', feature: 'leaderboards' },
{ label: 'Market', to: '/site/market', feature: 'market' }, { label: 'Market', to: '/site/market', feature: 'market' },
{ label: 'About', to: '/site/about' }, { label: 'About', to: '/site/about' },
@@ -48,7 +54,12 @@ const linkStyle = ({ isActive }) => ({
export default function SiteHeader() { export default function SiteHeader() {
const { user, loading } = useAuth() const { user, loading } = useAuth()
const { siteTitle, settings } = useSite() const { siteTitle, settings } = useSite()
const shardFeatures = useShardFeatures() 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 → // An admin may relabel, reorder and hide these entries from Admin →
// Navigation, and may group them into dropdown sections alongside links of // Navigation, and may group them into dropdown sections alongside links of
@@ -61,26 +72,10 @@ export default function SiteHeader() {
// never opens onto nothing; // never opens onto nothing;
// • with no stored row this is the coded NAV, in code order, so an // • with no stored row this is the coded NAV, in code order, so an
// untouched instance renders exactly what it renders today. // untouched instance renders exactly what it renders today.
// Installed modules' entries interleave into this list by `order` BEFORE the
// override merge, so an admin edits one nav rather than "core's, plus whatever
// the module appended" — and a module item is hideable and re-labelable
// exactly like a core one. `order` defaults high, which lands module entries
// where the UO items already sat: after the content links, before About.
const base = useMemo(() => {
const items = navFor('public')
if (items.length === 0) return NAV
const merged = [...NAV]
for (const item of items) {
const at = Number.isFinite(item.order) ? item.order : merged.length
merged.splice(Math.min(at, merged.length), 0, { label: item.label, to: item.to, feature: item.feature })
}
return merged
}, [])
const nav = useMemo(() => { const nav = useMemo(() => {
const tree = buildPublicNav(base, parseJsonSetting(settings.nav_public)) const tree = buildPublicNav(baseNav, parseJsonSetting(settings.nav_public))
return pruneNav(tree, (item) => !item.feature || canSee(shardFeatures, item.feature)) return pruneNav(tree, isVisible)
}, [base, settings.nav_public, shardFeatures]) }, [baseNav, settings.nav_public, isVisible])
// Where the auth entry points: staff → admin, player → portal, else sign in. // Where the auth entry points: staff → admin, player → portal, else sign in.
let account let account

View 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}/`),
)
}

View File

@@ -20,7 +20,10 @@
// Two shapes are supported, because two exist: // Two shapes are supported, because two exist:
// flat [{ to, label, ... }] — public header, player portal // flat [{ to, label, ... }] — public header, player portal
// grouped [{ title?, items: [{ to, label, ... }] }] — admin sidebar // grouped [{ title?, items: [{ to, label, ... }] }] — admin sidebar
function isGrouped(nav) { // 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)) return nav.length > 0 && nav.every((g) => g && Array.isArray(g.items))
} }

View File

@@ -56,3 +56,15 @@ export function useShardFeatures() {
export function canSee(features, name) { export function canSee(features, name) {
return !features || features.set.has(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
}

View File

@@ -3,24 +3,56 @@ import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom' import { BrowserRouter } from 'react-router-dom'
import App from './App.jsx' import App from './App.jsx'
import { publishSharedDependencies } from './modules/shared.js' import { publishSharedDependencies } from './modules/shared.js'
import { registerFeatureProvider } from './modules/registry.js'
import { useShardFlags } from './lib/useShardFeatures.js'
import './styles/theme.css' import './styles/theme.css'
// Publish window.__rg BEFORE rendering and before any module chunk evaluates. // Publish window.__rg BEFORE rendering and before any module chunk evaluates.
// Installed modules are `<script type="module" src="/modules/<id>/entry.js">` // Installed modules arrive as `<script type="module" src="/modules/<id>/…">`
// tags the server injects into <head> (server/src/utils/htmlShell.js); module // tags the server injects into the shell (server/src/utils/htmlShell.js), placed
// scripts are deferred, so they run after this bundle and resolve their // after this bundle's own tag; module scripts execute in document order, so they
// externals against the global this call sets up. // resolve their externals against the global this call sets up
// (docs/website/MODULE_API.md §3.2).
publishSharedDependencies() publishSharedDependencies()
// Render after DOMContentLoaded rather than immediately. // 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.
// //
// Deferred scripts execute in document order and all of them finish before // The owner id is `core`, which is what a nav row with no `moduleId` resolves
// DOMContentLoaded fires. Waiting for that event is therefore the guarantee that // against (modules/featureGate.js). The namespace is `uo`, so a module that
// every installed module has finished registering its routes and nav before // wants to read these flags — the `uo` module itself, once it owns them — asks
// React reads the registry — no loading state, no re-render, no ordering race // for them by the name they will always have had.
// between core's bundle and a module's. If this bundle happens to evaluate after registerFeatureProvider('core', 'uo', useShardFlags)
// the event has already fired (a cached, fast path), readyState is checked and
// render runs at once. // 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() { function mount() {
createRoot(document.getElementById('root')).render( createRoot(document.getElementById('root')).render(
<React.StrictMode> <React.StrictMode>
@@ -31,8 +63,8 @@ function mount() {
) )
} }
if (document.readyState === 'loading') { if (document.readyState === 'complete') {
document.addEventListener('DOMContentLoaded', mount, { once: true })
} else {
mount() mount()
} else {
document.addEventListener('DOMContentLoaded', mount, { once: true })
} }

View File

@@ -0,0 +1,56 @@
// Which nav rows a viewer may see, when the answer belongs to a module.
//
// Phase 2, PR 8 of docs/website/MODULE_SYSTEM.md §2.7 (§1.5 states the problem);
// the contract is docs/website/MODULE_API.md §3.3.
//
// 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

View File

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

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

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

View File

@@ -1,23 +1,37 @@
// ── The client-side module registry ──────────────────────────────────────── // ── The client-side module registry ────────────────────────────────────────
// //
// A module's prebuilt chunk registers its routes, nav entries and feature // Phase 2, PR 7 of docs/website/MODULE_SYSTEM.md §2.7. The normative contract is
// provider here, and App.jsx / the nav components read them back. This is the // docs/website/MODULE_API.md §3.3; where the two disagree, the contract wins.
// client half of docs/website/MODULE_API.md §3.3.
// //
// Timing is the whole design. Module chunks are `<script type="module" src>` // A module's prebuilt chunk registers its routes, its nav entries and its feature
// tags injected into <head> by the server (utils/htmlShell.js). Module scripts // provider here, and core reads them back. This is the client twin of the
// are deferred, so they evaluate after the SPA's own bundle has run — which is // server's modules/loader.js — with one structural difference worth stating,
// where window.__rg is published — and before DOMContentLoaded. main.jsx waits // because it is what makes the file this short: core *hands* the registry to the
// for that same event before calling render(), so registration is complete // module (on `window.__rg`, see shared.js) rather than discovering it. There is
// before React reads any of this and there is no re-render to orchestrate. // nothing to scan, nothing to validate a manifest against, and no failure mode
// where half a module is registered.
// //
// Registration is therefore a plain synchronous write with no subscribers, not // **Timing is the whole design.** Module chunks are `<script type="module" src>`
// an observable store. If that ever changes, it changes here and not in twelve // tags the server injects before `</body>` (server/src/utils/htmlShell.js), after
// consumers. // 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 routes = { public: [], admin: [], player: [] }
const nav = { public: [], admin: [], player: [] } const nav = { public: [], admin: [], player: [] }
const featureProviders = new Map() const providers = new Map()
const registered = new Set() const registered = new Set()
const AREAS = ['public', 'admin', 'player'] const AREAS = ['public', 'admin', 'player']
@@ -27,18 +41,26 @@ function assertArea(area, call) {
} }
/** /**
* Route components for one area. * Route components, by area.
* @param {string} id the module id, used to namespace the URL segment *
* @param {string} id the module id — the URL segment its routes are namespaced under
* @param {{public?: Array, admin?: Array, player?: Array}} byArea * @param {{public?: Array, admin?: Array, player?: Array}} byArea
* each entry `{ path, element, gate? }`; `path` is relative to the module's * each entry `{ path, element, gate? }`. `path` is relative to the module's
* namespace and core prefixes it (`/uo/…`, `/admin/uo/…`, `/player/uo/…`) * 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) { export function registerRoutes(id, byArea) {
for (const [area, list] of Object.entries(byArea || {})) { for (const [area, list] of Object.entries(byArea || {})) {
assertArea(area, 'registerRoutes') assertArea(area, 'registerRoutes')
for (const route of list) { for (const route of list || []) {
// Prefixed here rather than by the module, so a module cannot claim a path // Prefixed HERE rather than by the module: a module cannot claim a path
// outside its own namespace however it spells `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(/\/+$/, '') const path = `${id}/${String(route.path || '').replace(/^\/+/, '')}`.replace(/\/+$/, '')
routes[area].push({ ...route, path, moduleId: id }) routes[area].push({ ...route, path, moduleId: id })
} }
@@ -47,9 +69,14 @@ export function registerRoutes(id, byArea) {
} }
/** /**
* Nav entries, interleaved into CORE groups rather than appended as a block — * Nav entries, interleaved into CORE groups rather than appended as a block.
* today's UO items sit inside core's Moderation and System groups, and a "UO" *
* group at the bottom would be a visible regression (MODULE_SYSTEM.md §1.4). * 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 {string} id
* @param {{area: string, items: Array<{label, to, group?, order?, roles?, feature?}>}} spec * @param {{area: string, items: Array<{label, to, group?, order?, roles?, feature?}>}} spec
*/ */
@@ -57,38 +84,60 @@ export function registerNav(id, spec) {
const { area, items } = spec || {} const { area, items } = spec || {}
assertArea(area, 'registerNav') assertArea(area, 'registerNav')
for (const item of items || []) nav[area].push({ ...item, moduleId: id }) 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". * 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; with no *
* 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 * module installed the nav filter is a correct no-op, because no core nav item
* carries a `feature` today (MODULE_SYSTEM.md §1.5). * carries a `feature` today.
*/ */
export function registerFeatureProvider(id, namespace, hook) { export function registerFeatureProvider(id, namespace, hook) {
featureProviders.set(namespace, { id, hook }) providers.set(namespace, { id, hook })
registered.add(id)
} }
export const routesFor = (area) => routes[area] || [] export const routesFor = (area) => routes[area] || []
// Sorted by the `order` a module asked for, stable within equal orders so two // Sorted by the `order` a module asked for. Array#sort is stable in every engine
// modules registering the same slot stay in load (alphabetical id) order. // 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) => export const navFor = (area) =>
[...(nav[area] || [])].sort((a, b) => (a.order ?? 100) - (b.order ?? 100)) [...(nav[area] || [])].sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
export const featureProviderFor = (namespace) => featureProviders.get(namespace) 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] export const registeredIds = () => [...registered]
// Test seam. /** Test seam. Nothing in the app calls this — there is no unregistering. */
export function _reset() { export function _reset() {
for (const area of AREAS) { for (const area of AREAS) {
routes[area].length = 0 routes[area].length = 0
nav[area].length = 0 nav[area].length = 0
} }
featureProviders.clear() providers.clear()
registered.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 = { export const registry = {
registerRoutes, registerRoutes,
registerNav, registerNav,

View File

@@ -1,22 +1,31 @@
// ── window.__rg — the shared-dependency global ───────────────────────────── // ── window.__rg — the shared-dependency global ─────────────────────────────
// //
// A module's client half is a PREBUILT ESM chunk (the operator never builds // Phase 2, PR 7 of docs/website/MODULE_SYSTEM.md §2.7; the normative shape is
// anything), served same-origin, and loaded under `script-src 'self'` with no // docs/website/MODULE_API.md §3.2.
// 'unsafe-inline'. That combination is what rules out an import map: an import
// map has to be an inline <script type="importmap">, and CSP forbids it
// (MODULE_SYSTEM.md §1.14). So the shared dependencies ride on a global and the
// module's externals resolve against it — docs/website/MODULE_API.md §3.2.
// //
// There is exactly ONE React in the page and core owns it. A module that bundled // A module's client half is a PREBUILT ESM chunk — the operator never builds
// its own would get a second hook dispatcher and fail at the first useState. // 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 react from 'react'
import * as reactDom from 'react-dom/client' import * as reactDom from 'react-dom/client'
import * as router from 'react-router-dom' import * as router from 'react-router-dom'
// The automatic JSX runtime. Without this a module would have to build with // The automatic JSX runtime, and it is not decoration. A module's bundler
// `jsxRuntime: 'classic'` — its bundler emits `react/jsx-runtime` imports by // 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 one. // default, and those have to resolve to CORE's React like every other import.
// Exposing it here is what lets a module use the modern default. // 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 * as jsxRuntime from 'react/jsx-runtime'
import { registry } from './registry.js' import { registry } from './registry.js'
@@ -30,10 +39,22 @@ import { useAuth } from '../contexts/AuthContext.jsx'
import { useSite } from '../contexts/SiteContext.jsx' import { useSite } from '../contexts/SiteContext.jsx'
import { request, ApiError } from '../api/client.js' import { request, ApiError } from '../api/client.js'
// The kit is CURATED AND CLOSED, not a re-export of components/ — see §3.4. // The UI kit is CURATED AND CLOSED (§3.4), not a re-export of components/. These
// Adding to it is a minor MODULE_API_VERSION bump; changing a member's props is // seven are what the smallest UO page already needs beyond React and the router:
// a major one. That is a real constraint on core, and it is the price of module // without them a module either reaches into core's tree — violating the
// pages looking like the site they are installed in. // 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 = { const ui = {
PublicLayout, PublicLayout,
PageHeader, PageHeader,
@@ -45,12 +66,21 @@ const ui = {
useSite, useSite,
} }
// The request PRIMITIVE, not the api object: api.atlas and api.shard are module // The request PRIMITIVE, not the `api` object (§3.5). `api.atlas` and `api.shard`
// bindings that live in core's client today and move out with the module (§3.5). // are module bindings that only still live in core's client because Phase 3 has
// A module owns the paths it calls, which is right — it owns the routes at the // not moved them; a module builds its own namespace over `request` and owns the
// other end. // paths it calls — which is right, because it owns the routes at the other end.
const api = { request, ApiError } 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() { export function publishSharedDependencies() {
window.__rg = Object.freeze({ window.__rg = Object.freeze({
version: MODULE_API_VERSION, version: MODULE_API_VERSION,
@@ -62,4 +92,5 @@ export function publishSharedDependencies() {
ui: Object.freeze(ui), ui: Object.freeze(ui),
api: Object.freeze(api), api: Object.freeze(api),
}) })
return window.__rg
} }

View File

@@ -1,8 +1,14 @@
// The client's copy of MODULE_API_VERSION. Must equal the server's // The client's copy of MODULE_API_VERSION. It must equal the server's
// (server/src/modules/version.js) — they version ONE contract, and a module // (server/src/modules/version.js) — the two halves version ONE contract
// checks whichever half it is talking to. // (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: the value has to be on window.__rg before the // Duplicated rather than fetched, and that is deliberate. The value has to be on
// first module script evaluates, and that is earlier than any network round trip. // `window.__rg` before the first module chunk evaluates, which is earlier than
// A test asserts the two files agree. // 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' export const MODULE_API_VERSION = '1.0.0'

View File

@@ -6,6 +6,9 @@ import { useAuth } from '../../contexts/AuthContext.jsx'
import { useSite } from '../../contexts/SiteContext.jsx' import { useSite } from '../../contexts/SiteContext.jsx'
import { applyNavOverrides } from '../../lib/navOverrides.js' import { applyNavOverrides } from '../../lib/navOverrides.js'
import { useNavOverrides } from '../../lib/useNavOverrides.js' import { useNavOverrides } from '../../lib/useNavOverrides.js'
import { withModuleNav } from '../../modules/nav.js'
import { useFeatureGate } from '../../modules/features.jsx'
import { navItemVisibleTo, allowedPathsFor, isAllowedPath } from '../../lib/adminNav.js'
// Small inline stroke icons (16px, currentColor) — same style as ProviderIcon. // Small inline stroke icons (16px, currentColor) — same style as ProviderIcon.
// One shared frame keeps them terse; each item just supplies its path(s). // One shared frame keeps them terse; each item just supplies its path(s).
@@ -104,10 +107,6 @@ export const NAV = [
const COLLAPSE_KEY = 'admin.nav.collapsed' const COLLAPSE_KEY = 'admin.nav.collapsed'
// Moderators only get the moderation section (Discord + in-game ops) + their
// own account security.
const MOD_PATHS = ['/admin/moderation', '/admin/moderation/appeals', '/admin/shard-ops', '/admin/houses', '/admin/account']
// The one row an override may never hide: the nav editor itself, which is the // 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 // 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 — // (server/src/utils/navOverrides.js) and the editor's own toggle is disabled —
@@ -123,15 +122,11 @@ function keepEditorReachable(overrides) {
return { ...overrides, [UNHIDEABLE]: rest } return { ...overrides, [UNHIDEABLE]: rest }
} }
// Who may see a sidebar row. The single authority for that question: the layout // Who may see a sidebar row, and where that lets them go, both derived from the
// applies it after the override merge (overrides are presentation, this is the // row's own `roles` — lib/adminNav.js, which is where the two hardcoded path
// boundary — §7), and Admin -> Navigation applies it to build its palette, so an // lists this component used to carry went (MODULE_SYSTEM.md §1.4). Re-exported
// admin is never offered a row they cannot themselves see (§8.1). // because Admin -> Navigation has always imported it from here.
export function navItemVisibleTo(item, role) { export { navItemVisibleTo }
if (item.roles && !item.roles.includes(role)) return false
if (role === 'moderator') return MOD_PATHS.includes(item.to)
return true
}
const TITLES = { const TITLES = {
'/admin': 'Dashboard', '/admin': 'Dashboard',
@@ -159,6 +154,19 @@ const TITLES = {
'/admin/account': 'Account Security', '/admin/account': 'Account Security',
} }
// An installed module's admin pages are not in TITLES and cannot be — core does
// not know what they are called. Their nav row does, so the row is the title:
// the longest matching module row wins, so a detail page under a section titles
// as that section rather than falling through to a bare "Admin". Restricted to
// rows a module registered, which is what keeps every core path resolving
// through TITLES and sectionTitle exactly as it does today.
function moduleTitle(baseNav, pathname) {
return baseNav
.flatMap((g) => g.items)
.filter((i) => i.moduleId && (pathname === i.to || pathname.startsWith(`${i.to}/`)))
.sort((a, b) => b.to.length - a.to.length)[0]?.label
}
// Fallback page title for dynamic sub-routes not in the exact-match TITLES map. // Fallback page title for dynamic sub-routes not in the exact-match TITLES map.
function sectionTitle(pathname) { function sectionTitle(pathname) {
if (pathname.startsWith('/admin/moderation')) return 'Moderation' if (pathname.startsWith('/admin/moderation')) return 'Moderation'
@@ -186,7 +194,15 @@ export default function AdminLayout() {
const navOverrides = useNavOverrides() const navOverrides = useNavOverrides()
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const title = TITLES[location.pathname] || sectionTitle(location.pathname) const isVisible = useFeatureGate()
// Core's rows plus every installed module's, before the override merge sees
// them — so a module row is editable in Admin -> Navigation like any other
// (modules/nav.js). Computed once: the registry is fixed before the first
// render and nothing unregisters.
const baseNav = useMemo(() => withModuleNav(NAV, 'admin'), [])
const title =
TITLES[location.pathname] || moduleTitle(baseNav, location.pathname) || sectionTitle(location.pathname)
// The hero canvas editor needs room — let it use the full content width. // The hero canvas editor needs room — let it use the full content width.
const wide = location.pathname === '/admin/hero' const wide = location.pathname === '/admin/hero'
const modeDot = mode === 'live' ? 'var(--mode-live)' : 'var(--mode-maint)' const modeDot = mode === 'live' ? 'var(--mode-live)' : 'var(--mode-maint)'
@@ -200,11 +216,17 @@ export default function AdminLayout() {
// NAV itself and this is exactly the code that ran before the feature. // NAV itself and this is exactly the code that ran before the feature.
const navGroups = useMemo( const navGroups = useMemo(
() => () =>
applyNavOverrides(NAV, keepEditorReachable(navOverrides.nav_admin)) applyNavOverrides(baseNav, keepEditorReachable(navOverrides.nav_admin))
.map((g) => ({ ...g, items: g.items.filter((item) => navItemVisibleTo(item, user?.role)) })) .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. // Drop any now-empty group so an empty category header never renders.
.filter((g) => g.items.length > 0), .filter((g) => g.items.length > 0),
[navOverrides.nav_admin, user?.role], [baseNav, navOverrides.nav_admin, user?.role, isVisible],
) )
// Accordion: track which titled categories are collapsed. Persist across // Accordion: track which titled categories are collapsed. Persist across
@@ -231,17 +253,22 @@ export default function AdminLayout() {
g.title && g.items.some((i) => (i.end ? location.pathname === i.to : location.pathname.startsWith(i.to))) g.title && g.items.some((i) => (i.end ? location.pathname === i.to : location.pathname.startsWith(i.to)))
)?.title )?.title
// Where a moderator may go, from the same `roles` that decide what they see.
// It used to be a third hardcoded list — a prefix check over three paths —
// which disagreed with the sidebar's own five-path allowlist: `/admin/houses`
// was on the sidebar and not in the redirect, so a moderator who clicked
// Houses in their own nav was bounced straight back to Moderation. One
// derivation cannot disagree with itself, which is the point of deriving it.
const allowed = useMemo(() => allowedPathsFor(baseNav, user?.role), [baseNav, user?.role])
// Confine a moderator who deep-links (or is redirected to the index) to a page // Confine a moderator who deep-links (or is redirected to the index) to a page
// outside their remit — the API would 403 anyway, so send them to their home. // outside their remit — the API would 403 anyway, so send them to their home.
useEffect(() => { useEffect(() => {
if (!isModerator) return if (!isModerator) return
const p = location.pathname if (!isAllowedPath(location.pathname, allowed)) {
const allowed =
p.startsWith('/admin/moderation') || p.startsWith('/admin/shard-ops') || p === '/admin/account'
if (!allowed) {
navigate('/admin/moderation', { replace: true }) navigate('/admin/moderation', { replace: true })
} }
}, [isModerator, location.pathname, navigate]) }, [isModerator, location.pathname, navigate, allowed])
// Keep the admin out of search indexes (belt-and-suspenders with robots.txt). // Keep the admin out of search indexes (belt-and-suspenders with robots.txt).
useEffect(() => { useEffect(() => {

View File

@@ -13,7 +13,8 @@ import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { api } from '../../../api/client.js' import { api } from '../../../api/client.js'
import { useAuth } from '../../../contexts/AuthContext.jsx' import { useAuth } from '../../../contexts/AuthContext.jsx'
import { useSite } from '../../../contexts/SiteContext.jsx' import { useSite } from '../../../contexts/SiteContext.jsx'
import { useShardFeatures, canSee } from '../../../lib/useShardFeatures.js' import { withModuleNav } from '../../../modules/nav.js'
import { useFeatureGate } from '../../../modules/features.jsx'
import { buildNavRows, buildNavOverrides, buildPublicNav, buildPublicNavOverrides } from '../../../lib/navOverrides.js' import { buildNavRows, buildNavOverrides, buildPublicNav, buildPublicNavOverrides } from '../../../lib/navOverrides.js'
import PublicNavTree from './PublicNavTree.jsx' import PublicNavTree from './PublicNavTree.jsx'
import { parseJsonSetting } from '../../../lib/settingsJson.js' import { parseJsonSetting } from '../../../lib/settingsJson.js'
@@ -244,7 +245,7 @@ export function Row({ row, id, destinations, destination, onDestination, onChang
export default function NavEditor() { export default function NavEditor() {
const { user } = useAuth() const { user } = useAuth()
const { refresh: refreshSite } = useSite() const { refresh: refreshSite } = useSite()
const shardFeatures = useShardFeatures() const isVisible = useFeatureGate()
const [tab, setTab] = useState('nav_public') const [tab, setTab] = useState('nav_public')
// Per nav: the editable groups, the overrides as loaded (so a row this admin // 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. // cannot see survives their save), and whether a settings row exists at all.
@@ -255,25 +256,39 @@ export default function NavEditor() {
const [saved, setSaved] = useState('') const [saved, setSaved] = useState('')
const [dirty, setDirty] = useState({}) const [dirty, setDirty] = useState({})
// The palette: each base nav, filtered to what THIS admin can see (§8.1). The
// public nav's gates are the shard-feature ones; the admin nav's are roles.
// The player portal has no gates at all.
// The nav as coded, unfiltered. The palette below is what this admin may EDIT; // 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 // 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 // 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 // rather than reset, and only an entry for a route the code no longer declares
// at all should be dropped. // at all should be dropped.
const fullNavs = { nav_public: PUBLIC_NAV, nav_admin: ADMIN_NAV, nav_player: PLAYER_NAV } //
// 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( const palettes = useMemo(
() => ({ () => ({
nav_public: PUBLIC_NAV.filter((item) => !item.feature || canSee(shardFeatures, item.feature)), nav_public: fullNavs.nav_public.filter(isVisible),
nav_admin: ADMIN_NAV.map((g) => ({ ...g, items: g.items.filter((i) => navItemVisibleTo(i, user?.role)) })).filter( nav_admin: fullNavs.nav_admin
(g) => g.items.length > 0, .map((g) => ({ ...g, items: g.items.filter((i) => navItemVisibleTo(i, user?.role) && isVisible(i)) }))
), .filter((g) => g.items.length > 0),
nav_player: PLAYER_NAV, nav_player: fullNavs.nav_player.filter(isVisible),
}), }),
[shardFeatures, user?.role], [fullNavs, isVisible, user?.role],
) )
useEffect(() => { useEffect(() => {

View File

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

View File

@@ -6,6 +6,8 @@ import { useAuth } from '../../contexts/AuthContext.jsx'
import { useSite } from '../../contexts/SiteContext.jsx' import { useSite } from '../../contexts/SiteContext.jsx'
import { applyNavOverrides } from '../../lib/navOverrides.js' import { applyNavOverrides } from '../../lib/navOverrides.js'
import { useNavOverrides } from '../../lib/useNavOverrides.js' import { useNavOverrides } from '../../lib/useNavOverrides.js'
import { withModuleNav } from '../../modules/nav.js'
import { useFeatureGate } from '../../modules/features.jsx'
// Shared shell for the logged-in player portal. Uses the same sidebar shell as // Shared shell for the logged-in player portal. Uses the same sidebar shell as
// Admin (icon nav, sticky content header, footer sign-out) so the two logged-in // Admin (icon nav, sticky content header, footer sign-out) so the two logged-in
@@ -35,9 +37,10 @@ const IconGear = () => <Icon><circle cx="12" cy="12" r="3" /><path d="M12 2v3M12
const IconShield = () => <Icon><path d="M12 3l7 3v5c0 5-3.5 8-7 10-3.5-2-7-5-7-10V6z" /><path d="M9 12l2 2 4-4" /></Icon> const IconShield = () => <Icon><path d="M12 3l7 3v5c0 5-3.5 8-7 10-3.5-2-7-5-7-10V6z" /><path d="M9 12l2 2 4-4" /></Icon>
// Exported because Admin -> Navigation edits this list. It stays declared here; // Exported because Admin -> Navigation edits this list. It stays declared here;
// the editor may only relabel, reorder and hide what it finds (§7). No row // the editor may only relabel, reorder and hide what it finds (§7). No CORE row
// carries a gate — every player sees all three — so the merged result is what // carries a gate — every player sees all three — but an installed module's rows
// renders, with no filter after it. // join this list before the merge and may carry a `feature`, so the filter after
// it is not dead code.
export const NAV = [ export const NAV = [
{ to: '/player', label: 'Characters', end: true, icon: IconUser }, { to: '/player', label: 'Characters', end: true, icon: IconUser },
{ to: '/account/appeals', label: 'Appeals', icon: IconShield }, { to: '/account/appeals', label: 'Appeals', icon: IconShield },
@@ -69,7 +72,12 @@ export default function PlayerPortalLayout() {
const { user, logout } = useAuth() const { user, logout } = useAuth()
const { siteTitle } = useSite() const { siteTitle } = useSite()
const navOverrides = useNavOverrides() const navOverrides = useNavOverrides()
const nav = useMemo(() => applyNavOverrides(NAV, navOverrides.nav_player), [navOverrides.nav_player]) const isVisible = useFeatureGate()
const baseNav = useMemo(() => withModuleNav(NAV, 'player'), [])
const nav = useMemo(
() => applyNavOverrides(baseNav, navOverrides.nav_player).filter(isVisible),
[baseNav, navOverrides.nav_player, isVisible],
)
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const title = const title =

View File

@@ -1,7 +1,10 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { PublicLayout, PageHeader, Loading, ErrorState, EmptyState, useAsync } from '../ui.js' import PublicLayout from '../../components/PublicLayout.jsx'
import { api } from '../api.js' 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 ───────────────────────────────────────────────────────── // ── The spawn atlas ─────────────────────────────────────────────────────────
// //
@@ -49,7 +52,7 @@ function CreatureCard({ creature }) {
const facets = Object.entries(creature.facets || {}).sort((a, b) => b[1] - a[1]) const facets = Object.entries(creature.facets || {}).sort((a, b) => b[1] - a[1])
return ( return (
<Link <Link
to={`/uo/atlas/${encodeURIComponent(creature.slug)}`} to={`/site/atlas/${encodeURIComponent(creature.slug)}`}
className="panel" className="panel"
style={{ style={{
padding: '13px 15px', padding: '13px 15px',

View File

@@ -1,7 +1,10 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { Link, useParams } from 'react-router-dom' import { Link, useParams } from 'react-router-dom'
import { PublicLayout, PageHeader, Loading, ErrorState, EmptyState, useAsync } from '../ui.js' import PublicLayout from '../../components/PublicLayout.jsx'
import { api } from '../api.js' 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. // One creature: where it spawns, and what spawns alongside it.
// //
@@ -135,7 +138,7 @@ export default function AtlasCreature() {
<PublicLayout section="website"> <PublicLayout section="website">
<div className="shell-narrow page-body"> <div className="shell-narrow page-body">
<p className="sans" style={{ marginBottom: 8 }}> <p className="sans" style={{ marginBottom: 8 }}>
<Link to="/uo/atlas" style={{ color: 'var(--accent)', fontSize: '0.78rem' }}> <Link to="/site/atlas" style={{ color: 'var(--accent)', fontSize: '0.78rem' }}>
← Spawn atlas ← Spawn atlas
</Link> </Link>
</p> </p>
@@ -172,7 +175,7 @@ export default function AtlasCreature() {
{data.alsoHere.map((other) => ( {data.alsoHere.map((other) => (
<Link <Link
key={other.slug} key={other.slug}
to={`/uo/atlas/${encodeURIComponent(other.slug)}`} to={`/site/atlas/${encodeURIComponent(other.slug)}`}
className="sans" className="sans"
style={{ style={{
fontSize: '0.78rem', fontSize: '0.78rem',

View 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)
})

View File

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

View File

@@ -0,0 +1,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'])
})

View 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])
})

View File

@@ -35,6 +35,10 @@ services:
DB_HOST: db DB_HOST: db
UPLOAD_DIR: /app/uploads UPLOAD_DIR: /app/uploads
LOG_DIR: /app/logs LOG_DIR: /app/logs
# Where the loader scans for installed modules. Same path the code already
# defaults to (<repo>/modules, and the repo is /app in the image), set
# explicitly because the bind mount below is what makes it meaningful.
MODULES_DIR: /app/modules
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@@ -47,6 +51,25 @@ services:
# the image, so this mount only matters for custom brand images. Create # the image, so this mount only matters for custom brand images. Create
# ./brand/ on the host and drop assets in; read-only in the container. # ./brand/ on the host and drop assets in; read-only in the container.
- ./brand:/app/brand:ro - ./brand:/app/brand:ro
# Installed modules (docs/website/MODULE_SYSTEM.md). Modules live on a
# mount, NEVER in the image: that is what lets an operator add one to a
# pull-only deployment without building anything. A bind mount rather than
# a named volume because placing a module directory by hand is a supported
# install — `tar -xf uo-1.0.0.tgz -C ./modules` then restart — and that has
# to be doable from the host, not through `docker cp`.
#
# Read-WRITE: the admin panel's install/uninstall unpacks and removes
# directories here from inside the container.
#
# `modules/` is tracked (it ships a README) so the directory exists in the
# checkout with the operator's own ownership. Do not delete it — Docker
# would recreate a missing bind-mount source as root:root and the container
# user could no longer write it. If the app runs as a uid that does not own
# ./modules, `chown 1000:1000 modules` on the host.
#
# Adding or removing a module takes a RESTART: the scan is synchronous at
# require time (MODULE_API.md §4.1), so nothing here is picked up live.
- ./modules:/app/modules
# Only the PUBLIC API port (3000) is published. The internal server<->bot # Only the PUBLIC API port (3000) is published. The internal server<->bot
# port (INTERNAL_PORT, default 3001) is deliberately NOT listed here, so it # port (INTERNAL_PORT, default 3001) is deliberately NOT listed here, so it
# stays reachable only over the private compose network — Pangolin/the public # stays reachable only over the private compose network — Pangolin/the public

75
modules/README.md Normal file
View File

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

View File

@@ -1,50 +0,0 @@
# module-uo — the Phase 1 spike
**This branch is evidence, not implementation.** `spike/module-atlas` is cut from `edge` and is
never merged. Phase 2 rebuilds the loader properly, with the `installed_modules` table, the full
state machine and the admin panel behind it; Phase 3 does the real extraction.
What it demonstrates, and the results, are written up in
[`docs/website/MODULE_API.md`](../../../docs/website/MODULE_API.md) Part 7. In one line: the six
public spawn-atlas routes now live in a module, at byte-identical URLs, with the client half loading
as a prebuilt ESM chunk under `script-src 'self'`.
## Reproducing it
```bash
# 1. build the module's client chunk (its CI would do this and ship the result)
cd modules/uo/client && npm install && npm run build # → dist/entry.js
# 2. build core's client
cd ../../../client && npm install && npm run build
# 3. run the server against the local MariaDB
cd ../server && npm start
```
Then:
- `/uo/atlas` and `/uo/atlas/lizardman` render from the module's chunk.
- `GET /api/v1/public/atlas/*` answers exactly as before — `npm run routes:manifest -- --check`
reports the surface unchanged.
- `npm test` in `server/` (core, 729) and `node --test` in `modules/uo/server/` (module, 81).
`dist/entry.js` is committed here **only** because this branch is the evidence for a design
decision and a reviewer should be able to inspect the built artifact without a toolchain. A real
module publishes it from CI into its release bundle and never commits it.
## What in here is not design
Three things are consequences of stopping at six routes, spelled out in MODULE_API.md §7.5:
1. **Core reaches into this module twice** — `server/src/router/v1/admin/shardAtlas.controller.js`
and `server/test/atlasController.test.js`. The five admin atlas routes sit inside the `/shard`
admin prefix core still owns, so they cannot move until the whole prefix does.
2. **`server/utils/visibility.js` is a copy** of core's `utils/shardVisibility.js`, which core still
needs for the shard routes not yet extracted. Two caches over one table, briefly.
3. **There is no `swagger-fragment.json`** — it needs core's merge helper on the other side, which
is Phase 2.
Also note the table names here are `shard_*`, not `uo_*`. That is deliberate and grandfathered by an
allowlist in the loader: renaming twenty-seven live tables is a data migration this workstream does
not do. Every module written after this one carries its id as a table prefix.

View File

@@ -1,409 +0,0 @@
const B = window.__rg.jsxRuntime, { jsx: t, jsxs: l, Fragment: A } = B, U = window.__rg.react, {
useState: h,
useEffect: I,
useMemo: j,
useCallback: W,
useRef: ne,
useContext: se,
useReducer: re,
createElement: ie,
cloneElement: le,
createContext: oe,
forwardRef: ce,
memo: de,
Fragment: me,
Children: pe,
isValidElement: ue,
StrictMode: he,
Suspense: ge,
lazy: fe
} = U, E = window.__rg.router, {
Link: z,
NavLink: ye,
Navigate: xe,
Outlet: we,
Route: be,
Routes: ve,
useParams: M,
useNavigate: Se,
useLocation: Ne,
useSearchParams: $e,
createBrowserRouter: Ce,
RouterProvider: ke
} = E, { PublicLayout: F, PageHeader: D, Loading: C, ErrorState: v, EmptyState: S, useAsync: k, useAuth: Re, useSite: ze } = window.__rg.ui, { request: f } = window.__rg.api, w = (e) => e ? `?${e}` : "", O = {
creatures: (e = {}) => {
const a = new URLSearchParams();
return e.q && a.set("q", e.q), e.facet && a.set("facet", e.facet), e.limit && a.set("limit", e.limit), e.offset && a.set("offset", e.offset), f(`/public/atlas/creatures${w(a.toString())}`);
},
creature: (e, a = {}) => {
const s = new URLSearchParams();
return a.facet && s.set("facet", a.facet), a.points && s.set("points", a.points), f(`/public/atlas/creatures/${encodeURIComponent(e)}${w(s.toString())}`);
},
regions: (e = {}) => {
const a = new URLSearchParams();
return e.facet && a.set("facet", e.facet), e.q && a.set("q", e.q), f(`/public/atlas/regions${w(a.toString())}`);
},
landmarks: (e = {}) => {
const a = new URLSearchParams();
return e.facet && a.set("facet", e.facet), e.q && a.set("q", e.q), f(`/public/atlas/landmarks${w(a.toString())}`);
},
champions: (e = {}) => {
const a = new URLSearchParams();
return e.facet && a.set("facet", e.facet), f(`/public/atlas/champions${w(a.toString())}`);
},
meta: () => f("/public/atlas/meta")
}, y = { atlas: O }, H = 50, x = (e) => Number.isFinite(e) ? e.toLocaleString() : "—", Q = [
{ key: "creatures", label: "Creatures" },
{ key: "champions", label: "Champion altars" },
{ key: "places", label: "Places" }
];
function R({ active: e, onClick: a, children: s }) {
return /* @__PURE__ */ t(
"button",
{
type: "button",
onClick: a,
className: "sans",
style: {
fontSize: "0.78rem",
padding: "5px 12px",
borderRadius: 999,
cursor: "pointer",
color: e ? "var(--bg-deep)" : "var(--muted)",
background: e ? "var(--accent)" : "transparent",
border: `1px solid ${e ? "var(--accent)" : "var(--line)"}`
},
children: s
}
);
}
function G({ creature: e }) {
const a = Object.entries(e.facets || {}).sort((s, r) => r[1] - s[1]);
return /* @__PURE__ */ l(
z,
{
to: `/uo/atlas/${encodeURIComponent(e.slug)}`,
className: "panel",
style: {
padding: "13px 15px",
display: "flex",
alignItems: "center",
gap: 14,
textDecoration: "none",
color: "inherit"
},
children: [
/* @__PURE__ */ l("div", { style: { minWidth: 0, flex: 1 }, children: [
/* @__PURE__ */ t(
"div",
{
className: "display",
style: {
fontSize: "0.98rem",
color: "var(--head)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap"
},
children: e.name
}
),
/* @__PURE__ */ t("div", { className: "sans dim", style: { fontSize: "0.74rem", marginTop: 3 }, children: a.length === 0 ? "—" : a.map(([s, r]) => `${s} (${r})`).join(" · ") })
] }),
/* @__PURE__ */ l("div", { className: "sans", style: { flex: "none", textAlign: "right" }, children: [
/* @__PURE__ */ t("div", { style: { color: "var(--head)", fontSize: "0.92rem" }, children: x(e.total) }),
/* @__PURE__ */ l("div", { className: "dim", style: { fontSize: "0.68rem", letterSpacing: "0.05em" }, children: [
x(e.points),
" spawners"
] })
] })
]
}
);
}
function V({ q: e, facet: a }) {
const [s, r] = h({ loading: !0, error: null, items: [], total: 0 }), [i, p] = h(!1), o = W(
async (n) => await y.atlas.creatures({ q: e, facet: a, limit: H, offset: n }),
[e, a]
);
I(() => {
let n = !0;
return r({ loading: !0, error: null, items: [], total: 0 }), o(0).then((d) => {
n && r({ loading: !1, error: null, items: d.creatures || [], total: d.total || 0 });
}).catch((d) => n && r({ loading: !1, error: d, items: [], total: 0 })), () => {
n = !1;
};
}, [o]);
const c = async () => {
p(!0);
try {
const n = await o(s.items.length);
r((d) => ({ ...d, items: [...d.items, ...n.creatures || []], total: n.total ?? d.total }));
} catch {
} finally {
p(!1);
}
};
return s.loading ? /* @__PURE__ */ t(C, {}) : s.error ? /* @__PURE__ */ t(v, { message: "Could not load the bestiary right now." }) : s.items.length === 0 ? /* @__PURE__ */ t(S, { children: "Nothing in the atlas matches that." }) : /* @__PURE__ */ l(A, { children: [
/* @__PURE__ */ l("p", { className: "sans dim", style: { fontSize: "0.78rem", margin: "0 0 12px" }, children: [
"Showing ",
x(s.items.length),
" of ",
x(s.total)
] }),
/* @__PURE__ */ t("div", { style: { display: "flex", flexDirection: "column", gap: 8 }, children: s.items.map((n) => /* @__PURE__ */ t(G, { creature: n }, n.slug)) }),
s.items.length < s.total && /* @__PURE__ */ t("div", { style: { textAlign: "center", marginTop: 16 }, children: /* @__PURE__ */ t("button", { type: "button", className: "btn", onClick: c, disabled: i, children: i ? "Loading…" : "Load more" }) })
] });
}
function X({ facet: e }) {
const { loading: a, error: s, data: r } = k(() => y.atlas.champions(e), [e]);
return a ? /* @__PURE__ */ t(C, {}) : s ? /* @__PURE__ */ t(v, { message: "Could not load the champion altars right now." }) : !r || r.length === 0 ? /* @__PURE__ */ t(S, { children: "No champion altars are configured." }) : /* @__PURE__ */ t("div", { style: { display: "flex", flexDirection: "column", gap: 8 }, children: r.map((i) => /* @__PURE__ */ l("div", { className: "panel", style: { padding: "13px 15px", display: "flex", gap: 14, alignItems: "center" }, children: [
/* @__PURE__ */ l("div", { style: { minWidth: 0, flex: 1 }, children: [
/* @__PURE__ */ t("div", { className: "display", style: { fontSize: "0.98rem", color: "var(--head)" }, children: i.label || i.name }),
/* @__PURE__ */ l("div", { className: "sans dim", style: { fontSize: "0.74rem", marginTop: 3 }, children: [
i.facet,
i.group ? ` · ${i.group}` : "",
" · ",
i.x,
", ",
i.y
] })
] }),
/* @__PURE__ */ t("span", { className: "sans", style: { flex: "none", fontSize: "0.76rem", color: "var(--muted)" }, children: i.randomType ? "Random champion" : i.type || "—" })
] }, i.slug)) });
}
function J({ q: e, facet: a }) {
const { loading: s, error: r, data: i } = k(
() => Promise.all([y.atlas.regions({ q: e, facet: a }), y.atlas.landmarks({ q: e, facet: a })]),
[e, a]
), p = j(() => {
if (!i) return [];
const [o, c] = i;
return [
...o.map((n) => ({ key: `r:${n.facet}:${n.name}`, name: n.name, facet: n.facet, detail: n.parent || n.type || "Region", kind: "Region" })),
...c.map((n) => ({ key: `l:${n.facet}:${n.group || ""}:${n.name}:${n.x}:${n.y}`, name: n.group ? `${n.group} — ${n.name}` : n.name, facet: n.facet, detail: `${n.x}, ${n.y}`, kind: "Landmark" }))
].sort((n, d) => n.name.localeCompare(d.name));
}, [i]);
return s ? /* @__PURE__ */ t(C, {}) : r ? /* @__PURE__ */ t(v, { message: "Could not load places right now." }) : p.length === 0 ? /* @__PURE__ */ t(S, { children: "No regions or landmarks match that." }) : /* @__PURE__ */ t("div", { style: { display: "flex", flexDirection: "column", gap: 6 }, children: p.map((o) => /* @__PURE__ */ l("div", { className: "panel", style: { padding: "10px 14px", display: "flex", gap: 12, alignItems: "baseline" }, children: [
/* @__PURE__ */ t("span", { className: "sans", style: { flex: 1, minWidth: 0, color: "var(--head)", fontSize: "0.88rem" }, children: o.name }),
/* @__PURE__ */ l("span", { className: "sans dim", style: { fontSize: "0.72rem" }, children: [
o.facet,
" · ",
o.detail
] }),
/* @__PURE__ */ t("span", { className: "sans dim", style: { fontSize: "0.66rem", letterSpacing: "0.06em", flex: "none" }, children: o.kind })
] }, o.key)) });
}
function K() {
var L, _, q;
const [e, a] = h("creatures"), [s, r] = h(""), [i, p] = h(""), [o, c] = h(""), n = k(() => y.atlas.meta());
I(() => {
const m = setTimeout(() => p(s.trim()), 250);
return () => clearTimeout(m);
}, [s]);
const d = ((L = n.data) == null ? void 0 : L.facets) || [], u = ((_ = n.data) == null ? void 0 : _.counts) || null, N = (q = n.data) != null && q.importedAt ? new Date(n.data.importedAt) : null;
return /* @__PURE__ */ t(F, { section: "website", children: /* @__PURE__ */ l("div", { className: "shell-narrow page-body", children: [
/* @__PURE__ */ t(
D,
{
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."
}
),
u && /* @__PURE__ */ l("p", { className: "sans dim", style: { fontSize: "0.76rem", margin: "-12px 0 18px" }, children: [
x(u.creatures),
" creatures across ",
x(u.points),
" spawners",
Number.isFinite(u.unresolvedPoints) && u.points ? ` · ${Math.round((u.points - u.unresolvedPoints) / u.points * 100)}% placed to a named region or landmark` : "",
N ? ` · parsed ${N.toLocaleDateString()}` : ""
] }),
/* @__PURE__ */ t("div", { style: { display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 12 }, children: Q.map((m) => /* @__PURE__ */ t(R, { active: e === m.key, onClick: () => a(m.key), children: m.label }, m.key)) }),
e !== "champions" && /* @__PURE__ */ t(
"input",
{
className: "input",
type: "search",
value: s,
onChange: (m) => r(m.target.value),
placeholder: e === "creatures" ? "Search creatures…" : "Search regions and landmarks…",
style: { width: "100%", marginBottom: 12 }
}
),
d.length > 0 && /* @__PURE__ */ l("div", { style: { display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 18 }, children: [
/* @__PURE__ */ t(R, { active: o === "", onClick: () => c(""), children: "All facets" }),
d.map((m) => /* @__PURE__ */ t(R, { active: o === m, onClick: () => c(m), children: m }, m))
] }),
n.error && /* @__PURE__ */ t(v, { message: "Could not load the atlas right now." }),
!n.error && !n.loading && !N && /* @__PURE__ */ t(S, { children: "The spawn atlas has not been imported yet." }),
!n.error && N && /* @__PURE__ */ l(A, { children: [
e === "creatures" && /* @__PURE__ */ t(V, { q: i, facet: o }),
e === "champions" && /* @__PURE__ */ t(X, { facet: o }),
e === "places" && /* @__PURE__ */ t(J, { q: i, facet: o })
] })
] }) });
}
const g = (e) => Number.isFinite(e) ? e.toLocaleString() : "—";
function Y(e, a) {
const s = (r) => r >= 60 ? `${Math.round(r / 60)}m` : `${r}s`;
return !Number.isFinite(e) || !Number.isFinite(a) ? null : e === a ? s(e) : `${s(e)}–${s(a)}`;
}
function P({ title: e, right: a, children: s }) {
return /* @__PURE__ */ l("section", { className: "panel", style: { padding: 18 }, children: [
/* @__PURE__ */ l("div", { style: { display: "flex", alignItems: "baseline", justifyContent: "space-between", gap: 12 }, children: [
/* @__PURE__ */ t("h2", { className: "display", style: { margin: "0 0 12px", fontSize: "1.02rem", color: "var(--head)" }, children: e }),
a
] }),
s
] });
}
function Z({ places: e }) {
return e.length === 0 ? /* @__PURE__ */ t("p", { className: "sans dim", style: { margin: 0 }, children: "No placed spawners." }) : /* @__PURE__ */ t("div", { children: e.map((a) => /* @__PURE__ */ l(
"div",
{
className: "sans",
style: {
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
gap: 12,
padding: "6px 0",
borderBottom: "1px solid var(--line)",
fontSize: "0.86rem"
},
children: [
/* @__PURE__ */ t("span", { style: { minWidth: 0, color: "var(--head)" }, children: a.label }),
/* @__PURE__ */ l("span", { className: "dim", style: { flex: "none" }, children: [
a.facet,
" · ",
g(a.spawners),
" spawner",
a.spawners === 1 ? "" : "s",
" · up to",
" ",
g(a.maxAlive),
" at once"
] })
]
},
`${a.facet}:${a.label}`
)) });
}
function ee({ spawners: e, truncated: a }) {
const [s, r] = h(!1);
return e.length === 0 ? null : /* @__PURE__ */ t(
P,
{
title: "Individual spawners",
right: /* @__PURE__ */ t(
"button",
{
type: "button",
className: "sans",
onClick: () => r((i) => !i),
style: { background: "none", border: "none", color: "var(--accent)", cursor: "pointer", fontSize: "0.78rem" },
children: s ? "Hide" : `Show ${g(e.length)}`
}
),
children: s && /* @__PURE__ */ l("div", { style: { overflowX: "auto" }, children: [
/* @__PURE__ */ l("table", { className: "sans", style: { width: "100%", borderCollapse: "collapse", fontSize: "0.8rem" }, children: [
/* @__PURE__ */ t("thead", { children: /* @__PURE__ */ l("tr", { style: { textAlign: "left", color: "var(--muted)" }, children: [
/* @__PURE__ */ t("th", { style: { padding: "4px 8px 8px 0" }, children: "Place" }),
/* @__PURE__ */ t("th", { style: { padding: "4px 8px 8px 0" }, children: "Facet" }),
/* @__PURE__ */ t("th", { style: { padding: "4px 8px 8px 0" }, children: "Coords" }),
/* @__PURE__ */ t("th", { style: { padding: "4px 8px 8px 0" }, children: "Max" }),
/* @__PURE__ */ t("th", { style: { padding: "4px 0 8px 0" }, children: "Respawn" })
] }) }),
/* @__PURE__ */ t("tbody", { children: e.map((i) => /* @__PURE__ */ l("tr", { style: { borderTop: "1px solid var(--line)" }, children: [
/* @__PURE__ */ t("td", { style: { padding: "6px 8px 6px 0", color: "var(--head)" }, children: i.label }),
/* @__PURE__ */ t("td", { style: { padding: "6px 8px 6px 0" }, className: "dim", children: i.facet }),
/* @__PURE__ */ l("td", { style: { padding: "6px 8px 6px 0" }, className: "dim", children: [
i.x,
", ",
i.y
] }),
/* @__PURE__ */ t("td", { style: { padding: "6px 8px 6px 0" }, className: "dim", children: g(i.maxCount) }),
/* @__PURE__ */ t("td", { style: { padding: "6px 0" }, className: "dim", children: Y(i.minDelay, i.maxDelay) || "—" })
] }, i.id)) })
] }),
a && /* @__PURE__ */ t("p", { className: "sans dim", style: { fontSize: "0.74rem", margin: "10px 0 0" }, children: "Only the largest spawners are listed." })
] })
}
);
}
function te() {
var o;
const { slug: e } = M(), { loading: a, error: s, data: r } = k(() => y.atlas.creature(e), [e]), i = (s == null ? void 0 : s.status) === 404 || (s == null ? void 0 : s.message) === "Not Found", p = j(
() => Object.entries((r == null ? void 0 : r.facets) || {}).sort((c, n) => n[1] - c[1]),
[r]
);
return /* @__PURE__ */ t(F, { section: "website", children: /* @__PURE__ */ l("div", { className: "shell-narrow page-body", children: [
/* @__PURE__ */ t("p", { className: "sans", style: { marginBottom: 8 }, children: /* @__PURE__ */ t(z, { to: "/uo/atlas", style: { color: "var(--accent)", fontSize: "0.78rem" }, children: "← Spawn atlas" }) }),
a && /* @__PURE__ */ t(C, {}),
s && !i && /* @__PURE__ */ t(v, { message: "Could not load that creature right now." }),
i && /* @__PURE__ */ t(S, { children: "Nothing by that name spawns on this shard." }),
!a && !s && r && /* @__PURE__ */ l(A, { children: [
/* @__PURE__ */ t(
D,
{
eyebrow: "Bestiary",
title: r.name,
lead: `Up to ${g(r.total)} alive at once across ${g(r.points)} spawner${r.points === 1 ? "" : "s"}.`
}
),
/* @__PURE__ */ l("div", { style: { display: "flex", flexDirection: "column", gap: 12 }, children: [
/* @__PURE__ */ t(
P,
{
title: "Where it spawns",
right: /* @__PURE__ */ t("span", { className: "sans dim", style: { fontSize: "0.74rem" }, children: p.map(([c, n]) => `${c} (${n})`).join(" · ") }),
children: /* @__PURE__ */ t(Z, { places: r.places || [] })
}
),
/* @__PURE__ */ t(ee, { spawners: r.spawners || [], truncated: !!r.spawnersTruncated }),
((o = r.alsoHere) == null ? void 0 : o.length) > 0 && /* @__PURE__ */ t(P, { title: "Shares a spawner with", children: /* @__PURE__ */ t("div", { style: { display: "flex", flexWrap: "wrap", gap: 8 }, children: r.alsoHere.map((c) => /* @__PURE__ */ l(
z,
{
to: `/uo/atlas/${encodeURIComponent(c.slug)}`,
className: "sans",
style: {
fontSize: "0.78rem",
padding: "4px 11px",
borderRadius: 999,
border: "1px solid var(--line)",
color: "var(--muted)",
textDecoration: "none"
},
children: [
c.name,
" ",
/* @__PURE__ */ l("span", { className: "dim", children: [
"×",
g(c.shared)
] })
]
},
c.slug
)) }) })
] })
] })
] }) });
}
const $ = "uo", T = "^1.0.0";
function ae(e) {
const [a] = String(e || "").split(".");
return a === T.replace(/^\^/, "").split(".")[0];
}
const b = window.__rg;
b ? ae(b.version) ? (b.registry.registerRoutes($, {
// Paths are relative to the module's namespace; core prefixes them, so these
// render at /uo/atlas and /uo/atlas/:slug (MODULE_SYSTEM.md §2.8).
public: [
{ path: "atlas", element: /* @__PURE__ */ t(K, {}) },
{ path: "atlas/:slug", element: /* @__PURE__ */ t(te, {}) }
]
}), b.registry.registerNav($, {
area: "public",
items: [{ label: "Atlas", to: "/uo/atlas", feature: "atlas", order: 12 }]
})) : console.error(`[module-${$}] needs core API ${T}, this core is ${b.version} — not registering`) : console.error(`[module-${$}] window.__rg is missing — core did not publish its shared dependencies`);

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +0,0 @@
{
"name": "module-uo-client",
"private": true,
"version": "0.1.0-spike",
"type": "module",
"scripts": {
"build": "vite build"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.2",
"vite": "^5.4.8"
}
}

View File

@@ -1,47 +0,0 @@
// module-uo's API bindings.
//
// These used to be `api.atlas` inside core's client/src/api/client.js — a module
// namespace living in core (MODULE_API.md §3.5). The module owns the paths
// because it owns the routes at the other end; core hands over only the request
// primitive: same-origin /api/v1, cookies included, JSON in/out, ApiError on a
// non-2xx.
const { request } = window.__rg.api
const withQs = (s) => (s ? `?${s}` : '')
export const 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 request(`/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 request(`/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 request(`/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 request(`/public/atlas/landmarks${withQs(qs.toString())}`)
},
champions: (opts = {}) => {
const qs = new URLSearchParams()
if (opts.facet) qs.set('facet', opts.facet)
return request(`/public/atlas/champions${withQs(qs.toString())}`)
},
meta: () => request('/public/atlas/meta'),
}
export const api = { atlas }

View File

@@ -1,53 +0,0 @@
// ── module-uo · client entry point ─────────────────────────────────────────
//
// The prebuilt ESM chunk core loads as
// `<script type="module" src="/modules/uo/entry.js">`. Same-origin, so
// `script-src 'self'` admits it with no nonce and no inline — which is the
// entire reason the client half is shaped this way (MODULE_SYSTEM.md §1.14).
//
// It evaluates AFTER core's bundle (deferred module scripts run in document
// order) and BEFORE core renders (main.jsx waits for DOMContentLoaded), so
// registering synchronously here is enough — there is no loading state to
// coordinate and no re-render to trigger.
import Atlas from './pages/Atlas.jsx'
import AtlasCreature from './pages/AtlasCreature.jsx'
const ID = 'uo'
const CORE_API = '^1.0.0'
// The client-side twin of the server's coreApi check. A module built against a
// contract this core does not implement must refuse to register rather than
// half-work: a missing kit member is a blank page three clicks in, and the
// version is knowable now.
function compatible(version) {
const [major] = String(version || '').split('.')
return major === CORE_API.replace(/^\^/, '').split('.')[0]
}
const rg = window.__rg
if (!rg) {
// Not an exception: throwing from a module script is an uncaught error in the
// page, and a module failing to load must never be the site failing to load.
console.error(`[module-${ID}] window.__rg is missing — core did not publish its shared dependencies`)
} else if (!compatible(rg.version)) {
console.error(`[module-${ID}] needs core API ${CORE_API}, this core is ${rg.version} — not registering`)
} else {
rg.registry.registerRoutes(ID, {
// Paths are relative to the module's namespace; core prefixes them, so these
// render at /uo/atlas and /uo/atlas/:slug (MODULE_SYSTEM.md §2.8).
public: [
{ path: 'atlas', element: <Atlas /> },
{ path: 'atlas/:slug', element: <AtlasCreature /> },
],
})
// Interleaves into core's public nav rather than appending a "UO" group.
// `order: 12` puts it where the Atlas link already sat — after Wiki and the
// shard boards, before About. `feature` is resolved by the provider below.
rg.registry.registerNav(ID, {
area: 'public',
items: [{ label: 'Atlas', to: '/uo/atlas', feature: 'atlas', order: 12 }],
})
}

View File

@@ -1,3 +0,0 @@
const reactDom = window.__rg.reactDom
export default reactDom
export const { createRoot, hydrateRoot } = reactDom

View File

@@ -1,8 +0,0 @@
// The automatic JSX runtime, from core's global. Every .jsx file in this module
// compiles to imports from here, so this is the single hottest path in the
// bundle — and the one that would silently produce a SECOND React if it resolved
// to a bundled copy instead.
const jsx = window.__rg.jsxRuntime
export default jsx
export const { jsx: jsxFn, jsxs, Fragment } = jsx
export { jsxFn as jsx }

View File

@@ -1,10 +0,0 @@
// react-router-dom from core's global. Same singleton argument as React, with a
// sharper edge: the router's context is created by whichever copy is loaded, so
// a second copy would give module pages an EMPTY router context — <Link> would
// throw and useParams() would return {} rather than the URL's params.
const router = window.__rg.router
export default router
export const {
Link, NavLink, Navigate, Outlet, Route, Routes, useParams, useNavigate,
useLocation, useSearchParams, createBrowserRouter, RouterProvider,
} = router

View File

@@ -1,19 +0,0 @@
// React, taken from core's global rather than bundled.
//
// There is exactly ONE React in the page and core owns it (MODULE_API.md §3.2).
// A module that bundled its own would get a second hook dispatcher and fail at
// the first useState — so `react` is declared external in vite.config.js and
// aliased here.
//
// Why an alias module rather than rollup's `output.globals`: `globals` only
// applies to iife/umd output, and this is an ES module. An alias is the ESM
// equivalent, and it also keeps named imports (`import { useState } from
// 'react'`) working unchanged in the page source.
const react = window.__rg.react
export default react
export const {
useState, useEffect, useMemo, useCallback, useRef, useContext, useReducer,
createElement, cloneElement, createContext, forwardRef, memo, Fragment,
Children, isValidElement, StrictMode, Suspense, lazy,
} = react

View File

@@ -1,13 +0,0 @@
// Core's shared UI kit, from the global.
//
// This is the §3.4 kit: a curated, closed set — the layout chrome, the three
// page states, the async hook and the two read-only contexts. It exists because
// a module page that does not use core's layout is a module page that does not
// look like the site it is installed in, and drifts further every time core's
// chrome changes.
//
// Anything NOT in here, the module bundles itself.
const { PublicLayout, PageHeader, Loading, ErrorState, EmptyState, useAsync, useAuth, useSite } =
window.__rg.ui
export { PublicLayout, PageHeader, Loading, ErrorState, EmptyState, useAsync, useAuth, useSite }

View File

@@ -1,43 +0,0 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
// Library mode: one prebuilt ESM chunk, published by CI and dropped onto the
// operator's volume. The operator never builds anything (MODULE_SYSTEM.md §2.5).
//
// The four externals are the whole contract with core. Declaring them external
// alone is not enough, though: rollup would emit bare `import 'react'`
// specifiers, which a browser cannot resolve without an import map — and CSP
// forbids the inline <script type="importmap"> that would provide one. So each
// is ALIASED to a two-line shim that re-exports from window.__rg, and the
// external list then only has to stop Vite from following them into node_modules
// this package does not have.
const shim = (f) => path.resolve(import.meta.dirname, 'src/shim', f)
export default defineConfig({
plugins: [react()],
resolve: {
// EXACT matches, via the array form. Vite's object form does PREFIX
// replacement, so a plain `react` key also rewrote `react/jsx-runtime` into
// `src/shim/react.js/jsx-runtime` — a path that does not exist, and the
// first thing this build hit.
alias: [
{ find: /^react$/, replacement: shim('react.js') },
{ find: /^react\/jsx-runtime$/, replacement: shim('react-jsx-runtime.js') },
{ find: /^react-dom\/client$/, replacement: shim('react-dom-client.js') },
{ find: /^react-router-dom$/, replacement: shim('react-router-dom.js') },
],
},
build: {
lib: {
entry: path.resolve(import.meta.dirname, 'src/entry.jsx'),
formats: ['es'],
fileName: () => 'entry.js',
},
outDir: 'dist',
emptyOutDir: true,
// Same reason as core's client: no inline bootstrap script for
// `script-src 'self'` to trip on.
modulePreload: { polyfill: false },
},
})

View File

@@ -1,14 +0,0 @@
{
"id": "uo",
"name": "Ultima Online",
"version": "0.1.0-spike",
"coreApi": "^1.0.0",
"server": "server/index.js",
"client": { "entry": "client/dist/entry.js" },
"schema": "server/db/schema.sql",
"purge": "server/db/purge.sql",
"mounts": {
"public": ["/atlas"]
},
"capabilities": ["atlas"]
}

View File

@@ -1,88 +0,0 @@
// ── The module's single point of contact with core ─────────────────────────
//
// Every other file in this module imports THIS file instead of reaching into
// the website's tree. That is the whole mechanical trick behind the
// zero-internal-imports rule (docs/website/MODULE_API.md §5.1): the moved files
// changed by one `require` line each, and a CI grep for a relative path
// escaping the module root can then be an exact test rather than a heuristic.
//
// It exists because `ctx` arrives as an ARGUMENT to register(), while the files
// that need it are plain CommonJS modules that were written against top-level
// requires. Rather than thread ctx through nine constructors, register() parks
// it here once and everything else reads it lazily.
//
// Lazily is load-bearing: this file is required at module-require time, which is
// during app.js's own require, and reading `ctx.db` eagerly would rebuild the
// startup-time database dependency the loader is careful not to have.
let ctx = null
/** Called exactly once, by server/index.js, at the top of register(). */
function init(next) {
if (ctx) throw new Error('module-uo: core.init() called twice')
ctx = next
}
function require_() {
if (!ctx) throw new Error('module-uo: core used before register() ran')
return ctx
}
// Forwarders rather than re-exports: `const { query } = require('./core')`
// destructures at require time, which is before init(), so a plain re-export
// would capture undefined. Each of these resolves ctx at CALL time.
const query = (sql, params) => require_().db.query(sql, params)
const logger = (namespace) => require_().log(namespace)
const settings = {
get: (key) => require_().settings.get(key),
// `updatedBy` is the third parameter core's settings.model.set carries — the
// atlas path setter passes it (shardAtlas.model.js:60), so dropping it here
// would silently lose the audit attribution rather than fail.
set: (key, value, updatedBy) => require_().settings.set(key, value, updatedBy),
getInstanceName: () => require_().settings.getInstanceName(),
}
const auth = {
getUserFromRequest: (req) => require_().auth.getUserFromRequest(req),
}
const middleware = {
siteMode: (req, res, next) => require_().middleware.siteMode(req, res, next),
validate: (req, res, next) => require_().middleware.validate(req, res, next),
requireAuth: (req, res, next) => require_().middleware.requireAuth(req, res, next),
noindex: (req, res, next) => require_().middleware.noindex(req, res, next),
requireRole: (...roles) => {
// requireRole is a FACTORY, so it must be resolved at call time and the
// resulting middleware kept — resolving it per request would build a new
// closure on every hit.
let built = null
return (req, res, next) => {
built = built || require_().middleware.requireRole(...roles)
return built(req, res, next)
}
},
}
module.exports = {
init,
// Shared server dependencies, taken from core rather than required directly.
// A module lives outside server/, so `require('express')` from here does not
// resolve at all — and even where it did, a second express in the process
// would be a second Router prototype. Same rule as React on the client.
get express() { return require_().express },
get validator() { return require_().validator },
query,
logger,
settings,
auth,
middleware,
get pool() { return require_().db.pool },
get secretBox() { return require_().secretBox },
get push() { return require_().push },
get uploads() { return require_().uploads },
get posts() { return require_().posts },
get paths() { return require_().paths },
get moduleId() { return require_().moduleId },
}

View File

@@ -1,21 +0,0 @@
-- ── module-uo · purge ──────────────────────────────────────────────────────
--
-- DESTRUCTIVE. Run ONLY by the explicit admin purge action, never by uninstall
-- (docs/website/MODULE_API.md §2.6) — uninstalling a module removes its code and
-- retains its data, and an operator who wants the data gone has to say so.
--
-- Required because this module declares a schema fragment: a module that can
-- create tables and cannot drop them leaves an operator with orphaned data and
-- no supported way to remove it.
--
-- Dropped children-first even though these tables carry no foreign keys, so the
-- order stays correct if Phase 3 adds one.
DROP TABLE IF EXISTS shard_atlas_pending;
DROP TABLE IF EXISTS shard_atlas_meta;
DROP TABLE IF EXISTS shard_champion_spawns;
DROP TABLE IF EXISTS shard_landmarks;
DROP TABLE IF EXISTS shard_regions;
DROP TABLE IF EXISTS shard_spawn_point_types;
DROP TABLE IF EXISTS shard_spawn_points;
DROP TABLE IF EXISTS shard_spawn_creatures;

View File

@@ -1,166 +0,0 @@
-- ── module-uo · schema fragment ────────────────────────────────────────────
--
-- Replayed by core's ensureSchema() immediately after core's own schema.sql,
-- statement by statement, split the same way (docs/website/MODULE_API.md §2.6).
-- It inherits core's rules because it goes through core's splitter: idempotent
-- CREATE/ALTER only, no DROP, and no `--` inside a string literal.
--
-- SPIKE SCOPE: the eight spawn-atlas tables, lifted verbatim out of
-- server/db/schema.sql. Phase 3 brings the other nineteen.
--
-- These names are NOT `uo_`-prefixed, which the contract otherwise requires of a
-- module's tables. module-uo is grandfathered by an explicit allowlist in the
-- loader: renaming twenty-seven live tables is a data migration this workstream
-- deliberately does not do, and the prefix rule holds for every module written
-- after this one.
-- ── 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
-- comes from the sidecar — it is imported from a committed artifact built off a
-- ServUO tree by `npm run atlas:build` (see docs/website/SPAWN_ATLAS.md), so
-- these tables stay populated whether the shard is up or not.
--
-- Every table is import-owned: `npm run atlas:import` TRUNCATEs and reloads them
-- in one transaction. Nothing else may write here, and nothing else may hold a
-- foreign key to them. No FKs at all, consistent with every other shard_* table.
-- One row per spawnable type, aggregated across the world. `total` is the sum of
-- each type's own MX across every point that spawns it (how many exist at once);
-- `facets` is a per-facet point count, so the facet filter and "where does this
-- live" both answer without touching shard_spawn_points.
CREATE TABLE IF NOT EXISTS shard_spawn_creatures (
slug VARCHAR(120) NOT NULL PRIMARY KEY, -- slugified class name; the /atlas/:slug key
name VARCHAR(120) NOT NULL, -- display spelling chosen by the build
total INT NOT NULL DEFAULT 0,
points INT NOT NULL DEFAULT 0,
facets JSON NULL, -- { "Felucca": 171, "Trammel": 160, ... }
-- Operator-supplied artwork, always NULL on a fresh import. The repo ships no
-- creature art: sprites live in the operator's own client .mul/.uop files and
-- are theirs to extract and place under uploads/atlas/. The UI renders without
-- art when this is NULL, which is the normal case.
art VARCHAR(255) NULL,
-- Plain INDEX, deliberately NOT FULLTEXT: ~800 rows makes a LIKE scan free,
-- and FULLTEXT's min-token-length would break searches for names like "orc".
INDEX idx_shard_spawn_creatures_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- One row per spawner. `region`/`landmark` are the resolved place name — the
-- point-in-rect transform that turns "5411,1234" into "Despise" — and `label` is
-- the resolved display string (region, else landmark, else 'Wilderness').
CREATE TABLE IF NOT EXISTS shard_spawn_points (
id INT AUTO_INCREMENT PRIMARY KEY,
facet VARCHAR(40) NOT NULL,
name VARCHAR(120) NULL, -- the ServUO spawner's own name
x INT NOT NULL,
y INT NOT NULL,
width INT NOT NULL DEFAULT 0,
height INT NOT NULL DEFAULT 0,
spawn_range INT NOT NULL DEFAULT 0, -- `range` is reserved in MariaDB
max_count INT NOT NULL DEFAULT 0,
min_delay INT NOT NULL DEFAULT 0,
max_delay INT NOT NULL DEFAULT 0,
tod_start INT NOT NULL DEFAULT 0, -- meaningless unless tod_mode <> 0
tod_end INT NOT NULL DEFAULT 0,
tod_mode INT NOT NULL DEFAULT 0,
region VARCHAR(120) NULL,
landmark VARCHAR(120) NULL,
label VARCHAR(120) NOT NULL DEFAULT 'Wilderness',
INDEX idx_shard_spawn_points_facet (facet),
INDEX idx_shard_spawn_points_label (label)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- The many-to-many between the two above: one spawner commonly carries several
-- types (a single Trammel point spawns six), each with its own max. This is how
-- /atlas/creatures/:slug finds the places a creature appears.
CREATE TABLE IF NOT EXISTS shard_spawn_point_types (
point_id INT NOT NULL,
slug VARCHAR(120) NOT NULL, -- → shard_spawn_creatures.slug (no FK)
max_count INT NOT NULL DEFAULT 1,
PRIMARY KEY (point_id, slug),
INDEX idx_shard_spawn_point_types_slug (slug)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Named regions from Data/Regions.xml, flattened out of their nesting. `rects`
-- holds the region's rectangles; `priority` and rect area are what resolved each
-- spawn point at build time, kept here so the admin drift check can re-derive.
CREATE TABLE IF NOT EXISTS shard_regions (
id INT AUTO_INCREMENT PRIMARY KEY,
facet VARCHAR(40) NOT NULL,
name VARCHAR(120) NOT NULL,
type VARCHAR(80) NULL, -- ServUO region class
priority INT NOT NULL DEFAULT 0,
parent VARCHAR(120) NULL, -- enclosing named region, if any
rects JSON NULL,
INDEX idx_shard_regions_facet (facet),
INDEX idx_shard_regions_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Points of interest from Data/Locations/*.xml. `grp` is the innermost enclosing
-- parent ("Covetous"), which is the label worth showing — "Covetous" reads
-- better than the individual marker "Level 1". (`group` is reserved in SQL.)
CREATE TABLE IF NOT EXISTS shard_landmarks (
id INT AUTO_INCREMENT PRIMARY KEY,
facet VARCHAR(40) NOT NULL,
name VARCHAR(120) NOT NULL,
grp VARCHAR(120) NULL,
x INT NOT NULL,
y INT NOT NULL,
z INT NOT NULL DEFAULT 0,
INDEX idx_shard_landmarks_facet (facet),
INDEX idx_shard_landmarks_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Configured champion altars from Config/ChampionSpawns.xml. This is static
-- roster data ("there is an Unholy Terror altar in Deceit") and is distinct from
-- the live champ.update feed in shard_champs ("it is on level 3 right now").
CREATE TABLE IF NOT EXISTS shard_champion_spawns (
slug VARCHAR(160) NOT NULL PRIMARY KEY, -- facet-name, e.g. "felucca-deceit"
name VARCHAR(120) NOT NULL,
grp VARCHAR(80) NULL, -- spawn group; one active per group
type VARCHAR(80) NULL, -- '' when randomised per activation
random_type TINYINT(1) NOT NULL DEFAULT 0,
facet VARCHAR(40) NOT NULL,
x INT NOT NULL,
y INT NOT NULL,
z INT NOT NULL DEFAULT 0,
radius INT NOT NULL DEFAULT 0,
label VARCHAR(120) NULL, -- resolved place name
INDEX idx_shard_champion_spawns_facet (facet)
) 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
-- is behind the committed artifact.
CREATE TABLE IF NOT EXISTS shard_atlas_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_atlas_meta_singleton CHECK (id = 1)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Singleton (id = 1) holding an atlas refresh that was parsed but deliberately
-- NOT applied, because it would remove a facet the site currently serves.
--
-- Losing a facet is the signature of a half-copied or mid-update ServUO tree as
-- much as of a real map change, and boot cannot tell the two apart — so the
-- refresh is staged here for a human instead of being applied. Startup is never
-- blocked by it: the site comes up serving the atlas it already had.
--
-- Only the DECISION is stored, not the parsed world: `payload` holds the source
-- hashes and the facet diff (a few KB), and approving re-parses the tree. That
-- keeps a multi-megabyte blob out of the database and guarantees the applied
-- atlas matches the tree as it is at approval time, not as it was at boot.
--
-- `rejected` is remembered against those exact source hashes so a declined
-- refresh does not re-prompt on every restart; changing the tree changes the
-- hashes and asks again.
CREATE TABLE IF NOT EXISTS shard_atlas_pending (
id TINYINT NOT NULL PRIMARY KEY DEFAULT 1,
status ENUM('pending','rejected') NOT NULL DEFAULT 'pending',
payload JSON NOT NULL, -- source hashes + facet diff
detected_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT chk_shard_atlas_pending_singleton CHECK (id = 1)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@@ -1,59 +0,0 @@
// ── module-uo · server entry point ─────────────────────────────────────────
//
// SPIKE SCOPE. Phase 1 carries only /api/v1/public/atlas/* out of core
// (MODULE_SYSTEM.md §2.7): six routes, DB-backed, no sidecar, no SSE, one boot
// hook. Phase 3 brings the rest — the other 12 router/controller files, the 7
// remaining model directories, the notification-stream catalog and the
// town-crier announce leg.
//
// Called ONCE, synchronously, during the website's app.js require. Everything
// here must therefore be synchronous and must not touch the database: the route
// manifest generator and the OpenAPI generator both require app.js with the
// pool pointed at a dead port, and a module that queried here would hang both
// (MODULE_API.md §2.2). Anything needing a live database goes in onBoot.
const core = require('./core')
module.exports = function register(ctx, api) {
// Park ctx before requiring anything that reads it. The requires below pull in
// the model layer, whose files resolve core lazily — but the ORDER still
// matters for the router, which is constructed at require time.
core.init(ctx)
/* eslint-disable global-require */
const atlasRouter = require('./router/atlas.router')
const atlas = require('./model/shardAtlas/shardAtlas.model')
/* eslint-enable global-require */
const log = ctx.log('boot')
// The URL is unchanged from when this router lived in core's
// router/v1/public/index.js — that is the point, and routes.manifest.json is
// the proof (MODULE_API.md §5.3).
api.registerRoutes({
public: { '/atlas': atlasRouter },
})
// Was server.js:92, an explicit call in core's start(). Re-derive the spawn
// atlas from the shard's own ServUO tree: the shard's maps change over its
// lifetime — facets get added, replaced or renamed — so the atlas is rebuilt
// on every boot rather than shipped as a snapshot that would silently go
// stale. Hash-gated, so an unchanged tree costs one read pass and no write.
//
// Best-effort by contract: no configured path, an unreadable mount or a
// malformed file must never stop the site coming up, and a refresh that would
// REMOVE a facet is staged for admin approval instead of being applied. So it
// is caught HERE rather than left to the loader — the loader's catch would be
// correct about the failure but wrong about the severity, marking the module
// startup_failed and 503-ing six routes that serve perfectly good stale data.
api.onBoot(async () => {
try {
const result = await atlas.refreshOnBoot()
log.info('spawn atlas refreshed', { status: result && result.status })
} catch (err) {
log.warn('spawn atlas refresh failed — serving whatever was last imported', {
error: err.message,
})
}
})
}

View File

@@ -1,42 +0,0 @@
const { query } = require('../../core')
const COLS = 'account, user_id, char_name, linked_at'
// Upsert a link. account is the PK, so a re-link moves the account to the new
// user (the sidecar already treats /link/confirm as authoritative).
async function upsert({ account, userId, charName }) {
await query(
`INSERT INTO shard_account_links (account, user_id, char_name)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE user_id = VALUES(user_id), char_name = VALUES(char_name)`,
[account, userId, charName || null],
)
return getByAccount(account)
}
async function getByAccount(account) {
const rows = await query(`SELECT ${COLS} FROM shard_account_links WHERE account = ? LIMIT 1`, [account])
return rows[0] || null
}
const listByUser = (userId) =>
query(`SELECT ${COLS} FROM shard_account_links WHERE user_id = ? ORDER BY linked_at DESC`, [userId])
async function isOwnedBy(account, userId) {
const rows = await query(
'SELECT 1 FROM shard_account_links WHERE account = ? AND user_id = ? LIMIT 1',
[account, userId],
)
return rows.length > 0
}
const remove = (account, userId) =>
query('DELETE FROM shard_account_links WHERE account = ? AND user_id = ?', [account, userId])
// Drop the mirror for an account regardless of which user held it — used to
// reconcile when the tie is severed at the source (an in-game [unlink →
// account.unlinked event, or a site-side DELETE /link/{account}).
const removeByAccount = (account) =>
query('DELETE FROM shard_account_links WHERE account = ?', [account])
module.exports = { upsert, getByAccount, listByUser, isOwnedBy, remove, removeByAccount }

View File

@@ -1,37 +0,0 @@
// Site-side mirror of in-game-account → website-user links. The sidecar owns the
// authoritative link (it tags the game account on /link/confirm); this model
// records it locally so the player portal can list links and enforce ownership.
const db = require('./shardLinks.db')
function toSafe(row) {
if (!row) return null
return {
account: row.account,
userId: row.user_id,
charName: row.char_name || null,
linkedAt: row.linked_at,
}
}
async function link({ account, userId, charName }) {
return toSafe(await db.upsert({ account, userId, charName }))
}
async function listForUser(userId) {
const rows = await db.listByUser(userId)
return rows.map(toSafe)
}
const ownsAccount = (account, userId) => db.isOwnedBy(account, userId)
async function getByAccount(account) {
return toSafe(await db.getByAccount(account))
}
const unlink = (account, userId) => db.remove(account, userId)
// Drop the local mirror for an account (source-of-truth severed elsewhere).
const removeByAccount = (account) => db.removeByAccount(account)
module.exports = { link, listForUser, ownsAccount, getByAccount, unlink, removeByAccount }

View File

@@ -1,37 +0,0 @@
const { query } = require('../../core')
// One row per shard feature. Absent rows are fine — utils/shardVisibility.js
// compiles a default for every known feature and merges stored rows over it, so
// a fresh install with an empty table behaves exactly as the site did pre-v3.
const COLS = 'feature, enabled, audience, stream, field_rules, updated_by, updated_at'
const listAll = () => query(`SELECT ${COLS} FROM shard_feature_visibility`)
const getOne = (feature) =>
query(`SELECT ${COLS} FROM shard_feature_visibility WHERE feature = ?`, [feature])
// Upsert one feature's settings. `fieldRules` is stored as a JSON object of
// {field: rung}; the caller has already stripped locked fields and validated
// every rung against the ladder.
const upsert = ({ feature, enabled, audience, stream, fieldRules, updatedBy }) =>
query(
`INSERT INTO shard_feature_visibility (feature, enabled, audience, stream, field_rules, updated_by)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
enabled = VALUES(enabled),
audience = VALUES(audience),
stream = VALUES(stream),
field_rules = VALUES(field_rules),
updated_by = VALUES(updated_by)`,
[
feature,
enabled ? 1 : 0,
audience,
stream ? 1 : 0,
fieldRules == null ? null : JSON.stringify(fieldRules),
updatedBy ?? null,
],
)
module.exports = { listAll, getOne, upsert }

View File

@@ -1,44 +0,0 @@
// ── Shard feature visibility (model) ───────────────────────────────────────
//
// Thin row-shaping layer over shardVisibility.db. The policy — the ladder, the
// feature catalog, the locked fields, the kind→feature map — lives in
// utils/shardVisibility.js; this file only reads and writes rows.
const db = require('./shardVisibility.db')
// The `field_rules` JSON column comes back as a string on the mariadb driver.
function parseRules(raw) {
if (raw == null) return {}
if (typeof raw === 'object') return raw
try {
const parsed = JSON.parse(raw)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
} catch {
return {}
}
}
const toSafe = (row) =>
row && {
feature: row.feature,
enabled: !!row.enabled,
audience: row.audience,
stream: row.stream == null ? null : !!row.stream,
fieldRules: parseRules(row.field_rules),
updatedBy: row.updated_by,
updatedAt: row.updated_at,
}
async function listAll() {
const rows = await db.listAll()
return rows.map(toSafe)
}
async function getOne(feature) {
const rows = await db.getOne(feature)
return toSafe(rows[0])
}
const upsert = (entry) => db.upsert(entry)
module.exports = { listAll, getOne, upsert }

View File

@@ -1,60 +0,0 @@
// ── Test harness: a fake ctx ───────────────────────────────────────────────
//
// A module's tests cannot require core — that is the whole zero-internal-imports
// rule (docs/website/MODULE_API.md §5.1), and it applies to test files too. So
// instead of stubbing core's modules the way core's own tests do, a module test
// hands `core.init()` a ctx it fabricated.
//
// That turns out to be the nicer story: the seam that exists so a module can be
// swapped onto a different core is the same seam that lets its tests run with no
// database, no express app and no settings table. Core's tests reach the same
// place by pointing the mariadb pool at a dead port; a module does not have to.
const core = require('../core')
/**
* Build and install a fake ctx. Every member is a stub the test can reassign.
* @param {object} [over] members to override, deep-merged one level
*/
function installFakeCtx(over = {}) {
const settings = new Map()
const ctx = {
moduleId: 'uo',
paths: { moduleRoot: require('path').join(__dirname, '..', '..') },
// Null, not the real packages: a module cannot resolve express from outside
// server/ (that is why ctx carries them at all), and these tests construct no
// router. A test that needs one passes the real ones in `over`.
express: null,
validator: null,
db: {
// Every test that needs a query result reassigns this.
query: async () => [],
pool: { getConnection: async () => { throw new Error('no pool in tests') } },
},
log: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
settings: {
get: async (key) => (settings.has(key) ? settings.get(key) : null),
set: async (key, value) => { settings.set(key, value) },
getInstanceName: async () => 'Test Shard',
},
auth: { getUserFromRequest: () => null },
push: { publish: async () => {} },
secretBox: { encrypt: (s) => s, decrypt: (s) => s },
middleware: {
requireAuth: (req, res, next) => next(),
requireRole: () => (req, res, next) => next(),
siteMode: (req, res, next) => next(),
validate: (req, res, next) => next(),
noindex: (req, res, next) => next(),
},
uploads: {},
posts: {},
...over,
}
core.init(ctx)
return ctx
}
module.exports = { installFakeCtx }

View File

@@ -1,435 +0,0 @@
// ── Shard feature visibility ───────────────────────────────────────────────
//
// Admin-configurable, per-feature and per-field audience control over every
// shard-derived surface on the site. Replaces the hardcoded split that used to
// live in two places (the PUBLIC_KINDS allowlist in shardBroadcast.js, and the
// ad-hoc `canSeeStaffLocation` style checks in the public controllers).
//
// Design rules (docs/link/v3.md §3):
//
// • Visibility lives HERE, on the website — never in the sidecar. The sidecar
// is a dumb forwarder: it accepts frames, stores them, forwards them
// verbatim, and serves store-backed reads. It defines no audiences.
// • Every default reproduces the behavior that shipped before this module, so
// installing it changes nothing until an admin edits the config.
// • Two rules an admin CANNOT override:
// 1. `acct` / `webId` are admin-only, always. They are not in-game
// visible (unlike a character name) and are not configurable fields.
// 2. A kind absent from KIND_FEATURE is never broadcast below `admin`.
// Fail closed — this is what keeps the kind map a security boundary
// rather than a convenience filter.
//
// The audience ladder is ordered; each rung implies the ones below it.
const db = require('../model/shardVisibility/shardVisibility.model')
const shardLinks = require('../model/shardLinks/shardLinks.model')
const { auth } = require('../core')
const log = require('../core').logger('visibility')
// ── The ladder ─────────────────────────────────────────────────────────────
const LADDER = ['anonymous', 'logged_in', 'player', 'staff', 'admin']
const RANK = new Map(LADDER.map((level, i) => [level, i]))
const isLevel = (level) => RANK.has(level)
// The two fallbacks are deliberately ASYMMETRIC, and the asymmetry is the whole
// point: an unrecognised value must always lose. A single shared fallback cannot
// do that — whichever direction it picks, it fails open on one side. So:
//
// • an unknown VIEWER level floors to the bottom rung (grants nothing), and
// • an unknown REQUIREMENT ceils to the top rung (satisfied by nobody but admin).
//
// With one `rank()` defaulting to admin, a viewer level that fell through (a
// typo, a future rung this build doesn't know, a value from a caller that
// skipped viewerLevel) would have been treated as an ADMIN and passed every gate.
const viewerRank = (level) => RANK.get(level) ?? 0
const requiredRank = (level) => RANK.get(level) ?? RANK.get('admin')
// True when a viewer at `viewer` satisfies a requirement of `required`.
const meets = (viewer, required) => viewerRank(viewer) >= requiredRank(required)
// Exported for tests/diagnostics; `meets` is what callers should use.
const rank = viewerRank
// ── Features ───────────────────────────────────────────────────────────────
//
// All ten shard surfaces: the six that shipped before v3 plus the four v3 adds.
// `fields` lists only the SENSITIVE fields — those an admin may re-gate. A field
// not listed here is visible whenever the feature itself is.
//
// LOCKED_FIELDS are exempt from configuration entirely (rule 1 above).
const LOCKED_FIELDS = { acct: 'admin', webId: 'admin' }
// Rule 1 matches on the FIELD'S MEANING, not on one exact spelling. The wire
// frames nest actors (`leader.acct`), but several read models flatten them
// instead (`shapeHouse` emits `ownerAcct`, `shapeGuild`'s fallback emits
// `leaderAcct`/`leaderWebId`), and an exact-key check silently missed every
// flattened one — which is how `GET /public/shard/idoc` served `ownerAcct` to
// anonymous callers while the same account name was correctly stripped from the
// live `house.decay` frame.
//
// So a key is locked when it IS `acct`/`webId` or ENDS in one, case-insensitively
// (`ownerAcct`, `leaderWebId`, `governorAcct`). Suffix matching is what makes this
// fail closed for shapes nobody has written yet.
const LOCKED_SUFFIXES = ['acct', 'webid']
const isLockedField = (key) => {
const k = String(key).toLowerCase()
return LOCKED_SUFFIXES.some((suffix) => k === suffix || k.endsWith(suffix))
}
const FEATURES = {
// ── Shipped before v3. Defaults reproduce the previous hardcoded behavior. ──
status: { audience: 'anonymous', fields: {} },
activity: { audience: 'anonymous', fields: {} },
champs: { audience: 'anonymous', fields: {} },
guilds: { audience: 'anonymous', fields: {} },
governors: { audience: 'anonymous', fields: {} },
// The public Houses page showed IDOC location only; owner/price were staff.
// `owner` is the actor object on the house.decay/house.update frames;
// `ownerName`/`ownerSerial` are the flattened spellings shapeHouse emits on the
// REST read models. Both are listed so one rule covers the wire and the read
// model — the flattened `ownerAcct` needs no entry, being locked by rule 1.
houses: {
audience: 'anonymous',
fields: { owner: 'staff', ownerName: 'staff', ownerSerial: 'staff', price: 'staff' },
},
// /public/shard/online listed linked staff to everyone but gated location to
// admin+moderator — which is exactly the `staff` rung.
presence: { audience: 'anonymous', fields: { location: 'staff' } },
// ── New in v3. ──
ruleset: { audience: 'anonymous', fields: { connect: 'anonymous' } },
atlas: { audience: 'anonymous', fields: {} },
// `name` is the ranked character's name inside points.board's `top` entries, and
// it is spelled the way the WIRE spells it, not the way v3.md §7.4 describes it
// ("characterName"). projectValue matches on the literal JSON key, so a rule
// named for the field's meaning rather than its key silently does nothing — the
// same failure §3.6.1 records for the flattened `ownerAcct` spelling. Within a
// leaderboards payload `name` can only be a character name: the board's own
// display name arrives as `nameString`/`nameNumber`.
leaderboards: { audience: 'anonymous', fields: { name: 'anonymous' } },
// Shop name, owner character name and vendor location are already globally
// visible in-game via the stock Vendor Search gump, so publishing them is not
// a new disclosure — but they stay configurable so an admin can tighten them.
//
// `ownerName` and `location` were pre-wired here by Part A, before the frame
// existed; both were re-checked against the real `vendor.listing` and both are
// genuine keys on it (unlike leaderboards' `characterName`, which was inert).
// `location` is a NESTED object on the wire and on the read model precisely so
// that one rule hides map, coordinates, region and house together — five flat
// keys would be five rules that drift apart.
//
// `ownerSerial` is listed alongside `ownerName` for the same reason `houses`
// lists both: an admin who hides the owner's name and is left with a serial
// that every other board resolves back to that name has not hidden anything.
market: {
audience: 'anonymous',
fields: { ownerName: 'anonymous', ownerSerial: 'anonymous', location: 'anonymous' },
},
}
const FEATURE_NAMES = Object.keys(FEATURES)
const isFeature = (name) => Object.hasOwn(FEATURES, name)
// ── Kind → feature ─────────────────────────────────────────────────────────
//
// Every event kind that may ever leave the admin channel must appear here.
// Anything else is admin-only by omission (rule 2). This map is seeded from
// what PUBLIC_KINDS listed before v3, so the public stream carries exactly the
// same kinds it did — now attributed to a feature that an admin can re-gate.
const KIND_FEATURE = new Map(
Object.entries({
// status / lifecycle
'server.hello': 'status',
'server.shutdown': 'status',
'server.crashed': 'status',
'economy.supply': 'status',
// activity feed
'player.death': 'activity',
'player.murdered': 'activity',
'mob.killed': 'activity',
'quest.complete': 'activity',
'skill.gain': 'activity',
'fame.change': 'activity',
'karma.change': 'activity',
'mob.login': 'activity',
'mob.logout': 'activity',
// boards
'champ.update': 'champs',
'champ.remove': 'champs',
'guild.update': 'guilds',
'guild.remove': 'guilds',
'guild.join': 'guilds',
'city.update': 'governors',
'presence.online': 'presence',
'region.enter': 'presence',
// house.decay is the IDOC signal the public Houses page renders. The full
// registry (house.update / house.remove — owner, price, co-owners) stays
// off the map deliberately, so it remains admin-only exactly as before.
'house.decay': 'houses',
// v3
'world.ruleset': 'ruleset',
'points.board': 'leaderboards',
// vendor.listing IS mapped, but the market feature ships with its stream
// disabled (see DEFAULT_STREAM_OFF): a live firehose of full vendor
// inventories would be the site's biggest bandwidth consumer and no page
// needs it live. An admin can turn it on.
'vendor.listing': 'market',
'vendor.listing.remove': 'market',
}),
)
// Features whose SSE fan-out is off unless an admin enables it. The REST reads
// are unaffected; only the live stream is suppressed.
const DEFAULT_STREAM_OFF = new Set(['market'])
// Back-compat: the set of kinds that reach an anonymous viewer under the default
// config. shardEvents `/feed` filtering and notificationStreams.js both consume
// this. Derived from the map above rather than hand-maintained, so the two can
// no longer drift.
const PUBLIC_KINDS = new Set(
[...KIND_FEATURE.entries()]
.filter(([, feature]) => {
if (DEFAULT_STREAM_OFF.has(feature)) return false
return FEATURES[feature].audience === 'anonymous'
})
.map(([kind]) => kind),
)
// ── Config (DB-backed, cached) ─────────────────────────────────────────────
const CONFIG_TTL_MS = 5000
let cache = null
let cachedAt = 0
// Merge a stored row over its compiled default. Unknown feature names in the DB
// are ignored (a stale row from a removed feature must not resurrect it), and an
// invalid rung falls back to the default rather than failing open.
function applyRow(name, row) {
const base = FEATURES[name]
const audience = isLevel(row?.audience) ? row.audience : base.audience
const fields = { ...base.fields }
for (const [field, level] of Object.entries(row?.fieldRules || {})) {
if (isLockedField(field)) continue // rule 1: not configurable
if (isLevel(level)) fields[field] = level
}
return {
enabled: row ? !!row.enabled : true,
audience,
fields,
stream: row?.stream == null ? !DEFAULT_STREAM_OFF.has(name) : !!row.stream,
}
}
function compileDefaults() {
const out = {}
for (const name of FEATURE_NAMES) out[name] = applyRow(name, null)
return out
}
// Read the config, cached briefly. Falls back to compiled defaults if the DB is
// unreachable — the defaults reproduce pre-v3 behavior, so a DB blip degrades to
// "what the site did before" rather than to "everything is public".
async function getConfig() {
const now = Date.now()
if (cache && now - cachedAt < CONFIG_TTL_MS) return cache
try {
const rows = await db.listAll()
const byName = new Map(rows.map((r) => [r.feature, r]))
const out = {}
for (const name of FEATURE_NAMES) out[name] = applyRow(name, byName.get(name))
cache = out
cachedAt = now
} catch (err) {
log.error('getConfig; falling back to defaults', err)
cache = cache || compileDefaults()
cachedAt = now
}
return cache
}
const invalidate = () => {
cache = null
cachedAt = 0
}
// ── Viewer level ───────────────────────────────────────────────────────────
//
// anonymous no session
// logged_in authenticated, no linked game account
// player authenticated with a linked game account
// staff admin | moderator — the same set as the existing `modAccess` gate.
// `editor` is a CONTENT role with no shard privilege today, so it
// resolves by link status like any other member; mapping it to staff
// here would silently widen what editors can see.
// admin admin
//
// Staff always satisfy the `player` rung (rank order guarantees it) even without
// a linked account, matching the existing rule that /player/* is role-agnostic
// self-service.
// Same TTL as the config cache: this decides a privilege rung, so an unlinked
// (or newly relinked) account must not keep the old answer for long. Anonymous,
// staff and admin callers short-circuit before this runs, so the lookup only
// costs a query on the logged-in-member path.
const LINK_TTL_MS = CONFIG_TTL_MS
const linkCache = new Map() // userId → { hasLink, at }
async function hasLinkedAccount(userId) {
const hit = linkCache.get(userId)
const now = Date.now()
if (hit && now - hit.at < LINK_TTL_MS) return hit.hasLink
let hasLink = false
try {
const links = await shardLinks.listForUser(userId)
hasLink = Array.isArray(links) && links.length > 0
} catch (err) {
log.warn('hasLinkedAccount failed; treating as unlinked', { message: err.message })
}
linkCache.set(userId, { hasLink, at: now })
return hasLink
}
// Drop a user's cached link status (called when a link is created or removed so
// the rung takes effect immediately rather than up to LINK_TTL_MS later).
const forgetUser = (userId) => linkCache.delete(userId)
async function viewerLevel(req) {
const viewer = req.user || auth.getUserFromRequest(req)
if (!viewer) return 'anonymous'
if (viewer.role === 'admin') return 'admin'
if (viewer.role === 'moderator') return 'staff'
return (await hasLinkedAccount(viewer.id)) ? 'player' : 'logged_in'
}
// ── Enforcement ────────────────────────────────────────────────────────────
// Route gate. 404 when the feature is disabled (do not leak that it exists);
// 403 when it exists but the viewer sits below its audience. Stashes the
// resolved level on the request so controllers can project without re-resolving.
function requireFeature(name) {
return async (req, res, next) => {
try {
const config = await getConfig()
const feature = config[name]
if (!feature || !feature.enabled) return res.status(404).json({ message: 'Not Found' })
const level = await viewerLevel(req)
req.viewerLevel = level
if (!meets(level, feature.audience)) return res.status(403).json({ message: 'Forbidden' })
return next()
} catch (err) {
log.error(`requireFeature(${name})`, err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
}
// Strip the fields a viewer at `level` may not see. Applies the locked rules
// first (so acct/webId can never survive below admin), then the feature's
// configured field rules. Recurses into arrays and nested objects because the
// sensitive fields sit inside actor sub-objects (guild.leader, city.governor).
// Only ARRAYS and PLAIN objects are walked. A Date, Buffer or other class
// instance is a value, not a bag of fields: rebuilding one key-by-key would
// return `{}` (a Date has no enumerable own properties), which is how the DB-
// backed read models — whose rows carry real Date columns — differ from the
// pure-JSON wire frames the projection was first written against.
const isPlainObject = (v) => {
if (v === null || typeof v !== 'object') return false
const proto = Object.getPrototypeOf(v)
return proto === Object.prototype || proto === null
}
function projectValue(value, rules, level) {
if (Array.isArray(value)) return value.map((v) => projectValue(v, rules, level))
if (!isPlainObject(value)) return value
const out = {}
for (const [key, v] of Object.entries(value)) {
// Locked fields are checked by meaning first, so no configured rule (and no
// flattened spelling) can widen them past `admin`.
const required = isLockedField(key) ? 'admin' : rules[key]
if (required && !meets(level, required)) continue
out[key] = projectValue(v, rules, level)
}
return out
}
// Project a payload for one feature. `level` defaults to admin-equivalent only
// when explicitly passed; callers should always pass a resolved level.
function projectFeature(name, payload, level, config) {
const feature = config?.[name]
const rules = { ...LOCKED_FIELDS, ...(feature ? feature.fields : {}) }
return projectValue(payload, rules, level)
}
// Convenience for controllers: resolve config once, project, return.
async function project(name, payload, req) {
const config = await getConfig()
const level = req.viewerLevel || (await viewerLevel(req))
return projectFeature(name, payload, level, config)
}
// Is this event kind allowed to reach a viewer at `level`? Fail closed on an
// unmapped kind (rule 2), and honour both the feature gate and its stream flag.
function kindVisibleTo(kind, level, config) {
if (level === 'admin') return true
const name = KIND_FEATURE.get(kind)
if (!name) return false // rule 2: unmapped ⇒ admin-only
const feature = config?.[name]
if (!feature || !feature.enabled || !feature.stream) return false
return meets(level, feature.audience)
}
// The event kinds a viewer at `level` may read under the CURRENT config. This is
// the live counterpart of PUBLIC_KINDS, which is a module-load constant derived
// from the compiled DEFAULTS and therefore cannot answer "may THIS viewer see
// this kind, given what the admin has configured?".
//
// Deliberately ignores the `stream` flag: that governs SSE fan-out only, so a
// feature whose live firehose is off (market) is still readable from the stored
// history. Unmapped kinds are absent by construction (rule 2).
function visibleKinds(level, config) {
return [...KIND_FEATURE.entries()]
.filter(([, name]) => {
const feature = config?.[name]
return !!feature && feature.enabled && meets(level, feature.audience)
})
.map(([kind]) => kind)
}
// The features a viewer at `level` can actually see — drives SPA nav so it never
// renders a link that would 403.
function visibleFeatures(level, config) {
return FEATURE_NAMES.filter((name) => {
const feature = config[name]
return feature.enabled && meets(level, feature.audience)
})
}
module.exports = {
LADDER,
FEATURES,
FEATURE_NAMES,
LOCKED_FIELDS,
KIND_FEATURE,
PUBLIC_KINDS,
DEFAULT_STREAM_OFF,
isLevel,
isFeature,
isLockedField,
rank,
meets,
getConfig,
invalidate,
compileDefaults,
viewerLevel,
forgetUser,
requireFeature,
projectFeature,
project,
kindVisibleTo,
visibleKinds,
visibleFeatures,
}

View File

@@ -1106,33 +1106,192 @@ CREATE TABLE IF NOT EXISTS pages (
-- Announcement pipeline. One row per publish event of a news post; the table -- 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 -- 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 -- — sweeps it for due legs). `status` is a derived rollup of the legs (see
-- never blocks or retries the in-game town-crier leg and vice versa. `status` is -- announceJobs.logic.js): done when every leg is done, failed when every leg is
-- a derived rollup of the two legs (see announceJobs.logic.js): done when both -- exhausted, partial in between. post_id is INT (matches posts.id) and cascades
-- legs done, failed when both exhausted, partial in between. Each leg tracks its -- so deleting a post reaps its jobs. posts.announce_job_id points back at the
-- own attempt count, last error, and next-due time for exponential backoff. -- latest row for admin lookups.
-- 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 ( CREATE TABLE IF NOT EXISTS announce_jobs (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
post_id INT NOT NULL, post_id INT NOT NULL,
status ENUM('pending','partial','done','failed') NOT NULL DEFAULT 'pending', 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, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE 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, 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), ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INDEX idx_announce_due_discord (discord_status, discord_next_attempt_at)
-- 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
-- comes from the sidecar — it is imported from a committed artifact built off a
-- ServUO tree by `npm run atlas:build` (see docs/website/SPAWN_ATLAS.md), so
-- these tables stay populated whether the shard is up or not.
--
-- Every table is import-owned: `npm run atlas:import` TRUNCATEs and reloads them
-- in one transaction. Nothing else may write here, and nothing else may hold a
-- foreign key to them. No FKs at all, consistent with every other shard_* table.
-- One row per spawnable type, aggregated across the world. `total` is the sum of
-- each type's own MX across every point that spawns it (how many exist at once);
-- `facets` is a per-facet point count, so the facet filter and "where does this
-- live" both answer without touching shard_spawn_points.
CREATE TABLE IF NOT EXISTS shard_spawn_creatures (
slug VARCHAR(120) NOT NULL PRIMARY KEY, -- slugified class name; the /atlas/:slug key
name VARCHAR(120) NOT NULL, -- display spelling chosen by the build
total INT NOT NULL DEFAULT 0,
points INT NOT NULL DEFAULT 0,
facets JSON NULL, -- { "Felucca": 171, "Trammel": 160, ... }
-- Operator-supplied artwork, always NULL on a fresh import. The repo ships no
-- creature art: sprites live in the operator's own client .mul/.uop files and
-- are theirs to extract and place under uploads/atlas/. The UI renders without
-- art when this is NULL, which is the normal case.
art VARCHAR(255) NULL,
-- Plain INDEX, deliberately NOT FULLTEXT: ~800 rows makes a LIKE scan free,
-- and FULLTEXT's min-token-length would break searches for names like "orc".
INDEX idx_shard_spawn_creatures_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- One row per spawner. `region`/`landmark` are the resolved place name — the
-- point-in-rect transform that turns "5411,1234" into "Despise" — and `label` is
-- the resolved display string (region, else landmark, else 'Wilderness').
CREATE TABLE IF NOT EXISTS shard_spawn_points (
id INT AUTO_INCREMENT PRIMARY KEY,
facet VARCHAR(40) NOT NULL,
name VARCHAR(120) NULL, -- the ServUO spawner's own name
x INT NOT NULL,
y INT NOT NULL,
width INT NOT NULL DEFAULT 0,
height INT NOT NULL DEFAULT 0,
spawn_range INT NOT NULL DEFAULT 0, -- `range` is reserved in MariaDB
max_count INT NOT NULL DEFAULT 0,
min_delay INT NOT NULL DEFAULT 0,
max_delay INT NOT NULL DEFAULT 0,
tod_start INT NOT NULL DEFAULT 0, -- meaningless unless tod_mode <> 0
tod_end INT NOT NULL DEFAULT 0,
tod_mode INT NOT NULL DEFAULT 0,
region VARCHAR(120) NULL,
landmark VARCHAR(120) NULL,
label VARCHAR(120) NOT NULL DEFAULT 'Wilderness',
INDEX idx_shard_spawn_points_facet (facet),
INDEX idx_shard_spawn_points_label (label)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- The many-to-many between the two above: one spawner commonly carries several
-- types (a single Trammel point spawns six), each with its own max. This is how
-- /atlas/creatures/:slug finds the places a creature appears.
CREATE TABLE IF NOT EXISTS shard_spawn_point_types (
point_id INT NOT NULL,
slug VARCHAR(120) NOT NULL, -- → shard_spawn_creatures.slug (no FK)
max_count INT NOT NULL DEFAULT 1,
PRIMARY KEY (point_id, slug),
INDEX idx_shard_spawn_point_types_slug (slug)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Named regions from Data/Regions.xml, flattened out of their nesting. `rects`
-- holds the region's rectangles; `priority` and rect area are what resolved each
-- spawn point at build time, kept here so the admin drift check can re-derive.
CREATE TABLE IF NOT EXISTS shard_regions (
id INT AUTO_INCREMENT PRIMARY KEY,
facet VARCHAR(40) NOT NULL,
name VARCHAR(120) NOT NULL,
type VARCHAR(80) NULL, -- ServUO region class
priority INT NOT NULL DEFAULT 0,
parent VARCHAR(120) NULL, -- enclosing named region, if any
rects JSON NULL,
INDEX idx_shard_regions_facet (facet),
INDEX idx_shard_regions_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Points of interest from Data/Locations/*.xml. `grp` is the innermost enclosing
-- parent ("Covetous"), which is the label worth showing — "Covetous" reads
-- better than the individual marker "Level 1". (`group` is reserved in SQL.)
CREATE TABLE IF NOT EXISTS shard_landmarks (
id INT AUTO_INCREMENT PRIMARY KEY,
facet VARCHAR(40) NOT NULL,
name VARCHAR(120) NOT NULL,
grp VARCHAR(120) NULL,
x INT NOT NULL,
y INT NOT NULL,
z INT NOT NULL DEFAULT 0,
INDEX idx_shard_landmarks_facet (facet),
INDEX idx_shard_landmarks_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Configured champion altars from Config/ChampionSpawns.xml. This is static
-- roster data ("there is an Unholy Terror altar in Deceit") and is distinct from
-- the live champ.update feed in shard_champs ("it is on level 3 right now").
CREATE TABLE IF NOT EXISTS shard_champion_spawns (
slug VARCHAR(160) NOT NULL PRIMARY KEY, -- facet-name, e.g. "felucca-deceit"
name VARCHAR(120) NOT NULL,
grp VARCHAR(80) NULL, -- spawn group; one active per group
type VARCHAR(80) NULL, -- '' when randomised per activation
random_type TINYINT(1) NOT NULL DEFAULT 0,
facet VARCHAR(40) NOT NULL,
x INT NOT NULL,
y INT NOT NULL,
z INT NOT NULL DEFAULT 0,
radius INT NOT NULL DEFAULT 0,
label VARCHAR(120) NULL, -- resolved place name
INDEX idx_shard_champion_spawns_facet (facet)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- UO's localization table: cliloc id -> display string. Items carry a -- UO's localization table: cliloc id -> display string. Items carry a
@@ -1168,6 +1327,84 @@ CREATE TABLE IF NOT EXISTS shard_cliloc_meta (
CONSTRAINT chk_shard_cliloc_meta_singleton CHECK (id = 1) CONSTRAINT chk_shard_cliloc_meta_singleton CHECK (id = 1)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) 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
-- is behind the committed artifact.
CREATE TABLE IF NOT EXISTS shard_atlas_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_atlas_meta_singleton CHECK (id = 1)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Singleton (id = 1) holding an atlas refresh that was parsed but deliberately
-- NOT applied, because it would remove a facet the site currently serves.
--
-- Losing a facet is the signature of a half-copied or mid-update ServUO tree as
-- much as of a real map change, and boot cannot tell the two apart — so the
-- refresh is staged here for a human instead of being applied. Startup is never
-- blocked by it: the site comes up serving the atlas it already had.
--
-- Only the DECISION is stored, not the parsed world: `payload` holds the source
-- hashes and the facet diff (a few KB), and approving re-parses the tree. That
-- keeps a multi-megabyte blob out of the database and guarantees the applied
-- atlas matches the tree as it is at approval time, not as it was at boot.
--
-- `rejected` is remembered against those exact source hashes so a declined
-- refresh does not re-prompt on every restart; changing the tree changes the
-- hashes and asks again.
CREATE TABLE IF NOT EXISTS shard_atlas_pending (
id TINYINT NOT NULL PRIMARY KEY DEFAULT 1,
status ENUM('pending','rejected') NOT NULL DEFAULT 'pending',
payload JSON NOT NULL, -- source hashes + facet diff
detected_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
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 -- 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 -- 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. -- these columns from the CREATE TABLE above; existing installs get them here.

View File

@@ -10,7 +10,7 @@
"swagger": "node swagger/swagger.js", "swagger": "node swagger/swagger.js",
"routes:manifest": "node scripts/routeManifest.js", "routes:manifest": "node scripts/routeManifest.js",
"atlas:import": "node scripts/importSpawnAtlas.js", "atlas:import": "node scripts/importSpawnAtlas.js",
"test": "node --test" "test": "node --test --require ./test/_setup.js"
}, },
"keywords": [ "keywords": [
"express", "express",

View File

@@ -1062,7 +1062,7 @@
{ {
"method": "DELETE", "method": "DELETE",
"path": "/api/v1/admin/users/:id/shard/link/:account", "path": "/api/v1/admin/users/:id/shard/link/:account",
"handlers": 5, "handlers": 4,
"gates": [ "gates": [
"noindex", "noindex",
"requireAuth", "requireAuth",
@@ -1947,6 +1947,12 @@
"validate" "validate"
] ]
}, },
{
"method": "GET",
"path": "/api/v1/public/modules",
"handlers": 1,
"gates": []
},
{ {
"method": "GET", "method": "GET",
"path": "/api/v1/public/pages/:id/preview/:token", "path": "/api/v1/public/pages/:id/preview/:token",

View File

@@ -781,6 +781,10 @@
"method": "POST", "method": "POST",
"path": "/api/v1/public/contact" "path": "/api/v1/public/contact"
}, },
{
"method": "GET",
"path": "/api/v1/public/modules"
},
{ {
"method": "GET", "method": "GET",
"path": "/api/v1/public/pages/:id/preview/:token" "path": "/api/v1/public/pages/:id/preview/:token"

View File

@@ -16,10 +16,11 @@
* derived (only annotated routes appear) and documents intent; this records reality. * derived (only annotated routes appear) and documents intent; this records reality.
* *
* Scope: only `/api/**` and `/.well-known/**` from the public app, plus everything * 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 * on the internal app. Four mounts in app.js are *filesystem* conditional — the SPA
* catch-all `GET *`, the `/brand` static mount and swagger-ui's `/api/docs` static * catch-all `GET *`, the `/brand` static mount, installed modules' `/modules/<id>`
* assets — so including them would make the output depend on whether CI had built * chunks and swagger-ui's `/api/docs` static assets — so including them would make
* the client. Static mounts are not API contract. * the output depend on whether CI had built the client, or on which modules were
* mounted. Static mounts are not API contract.
* *
* Usage: * Usage:
* npm run routes:manifest # write server/routes.manifest.json (+ guards) * npm run routes:manifest # write server/routes.manifest.json (+ guards)
@@ -56,7 +57,8 @@ const GUARDS_COMMENT =
'`npm run routes:manifest`.' '`npm run routes:manifest`.'
// Only these prefixes are contract. Everything else the public app serves (SPA // 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/'] 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 * 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 * literal mount (`/api/v1`) that is `^\/api\/v1\/?(?=\/|$)`; a parameterised mount
* contributes one `(?:([^\/]+?))` group per entry in `layer.keys`. Unwinding both * contributes one group per entry in `layer.keys`, and the separator before the
* gets us back to `/api/v1` and `/thing/:id` respectively. `fast_slash` is * parameter lives INSIDE that group — express 4.22 compiles `use('/:id', r)` to
* express's marker for a router mounted at the root, which contributes nothing. * `^(?:\/([^/]+?))\/?(?=\/|$)`. 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) { function mountPath(layer) {
const re = layer.regexp const re = layer.regexp
@@ -79,9 +88,11 @@ function mountPath(layer) {
const keys = layer.keys || [] const keys = layer.keys || []
let i = 0 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++] 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. // Whatever is left should be a literal path with regexp-escaped separators.

View File

@@ -10,6 +10,8 @@ require('dotenv').config()
const swaggerUi = require('swagger-ui-express') const swaggerUi = require('swagger-ui-express')
const apiRouter = require('./router/api.router') const apiRouter = require('./router/api.router')
const modules = require('./modules/loader')
const registries = require('./modules/registries')
const wellKnown = require('./router/wellKnown.controller') const wellKnown = require('./router/wellKnown.controller')
const cspReport = require('./router/cspReport.controller') const cspReport = require('./router/cspReport.controller')
const brand = require('./config/brand') const brand = require('./config/brand')
@@ -19,7 +21,6 @@ const createLogger = require('./utils/logger')
const htmlShell = require('./utils/htmlShell') const htmlShell = require('./utils/htmlShell')
const { applyTrustProxy, trustProxyDebug } = require('./utils/trustProxy') const { applyTrustProxy, trustProxyDebug } = require('./utils/trustProxy')
const botScore = require('./middleware/botScore') const botScore = require('./middleware/botScore')
const modules = require('./modules/loader')
const httpLog = createLogger('http') const httpLog = createLogger('http')
const errLog = createLogger('error') const errLog = createLogger('error')
@@ -109,34 +110,6 @@ app.use(
}), }),
) )
// ── Installed modules ─────────────────────────────────────────────────
// Discovered synchronously from the filesystem, with no database (see
// modules/loader.js for why that is not negotiable). The scan has already run by
// the time the routers below are required; calling it here makes the ordering
// explicit rather than incidental.
modules.scan()
// A module's prebuilt client chunk, served same-origin at /modules/<id>/*.
// Same-origin is the whole point: CSP is `script-src 'self'` with no
// 'unsafe-inline' (config/csp.js:49), so this loads with no nonce and no import
// map — see docs/website/MODULE_API.md §3.1.
//
// **One static mount PER MODULE, rooted at that module's client dist** — never
// one mount over the modules directory. A module holds its server source, its
// module.json and its schema fragment alongside the client build; a single
// `express.static(modulesDir)` would publish all of it. This serves exactly the
// directory the module nominated as its browser bundle and nothing above it.
for (const mod of modules.list()) {
if (!mod.clientDir || !fs.existsSync(mod.clientDir)) continue
app.use(
`/modules/${mod.id}`,
express.static(mod.clientDir, {
index: false,
setHeaders: (res) => res.set('X-Content-Type-Options', 'nosniff'),
}),
)
}
// ── API docs (Swagger UI) ───────────────────────────────────────────── // ── API docs (Swagger UI) ─────────────────────────────────────────────
// Interactive OpenAPI docs at /api/docs, raw spec at /api/docs.json. The spec // Interactive OpenAPI docs at /api/docs, raw spec at /api/docs.json. The spec
// is generated from route annotations by `npm run swagger` (server/swagger/). // is generated from route annotations by `npm run swagger` (server/swagger/).
@@ -183,8 +156,77 @@ app.get(
app.post(csp.REPORT_PATH, cspReportLimiter, ...cspReport.parsers, cspReport.receive) app.post(csp.REPORT_PATH, cspReportLimiter, ...cspReport.parsers, cspReport.receive)
app.use('/api', apiRouter) 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' })) 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 ────────────────────────────────────────────────────── // ── /.well-known ──────────────────────────────────────────────────────
// Android App Links verification file at the web root (M9 follow-up). Mounted // 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 // before the SPA catch-all so it returns JSON, not the index shell. 404s unless

View 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 }

View File

@@ -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 // MODULE-UO CONTENT, still living in core. MODULE_SYSTEM.md §1.8 named
// a shard event maps onto them. Two families: // 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 // • public / opt-in — no linked game account required; delivered to every
// subscriber. Drawn ONLY from the SSE public allowlist // subscriber. Drawn ONLY from the SSE public allowlist
// (utils/shardBroadcast PUBLIC_KINDS) — a sensitive kind // (utils/shardBroadcast PUBLIC_KINDS) — a sensitive kind
@@ -17,17 +24,7 @@
const { PUBLIC_KINDS } = require('../utils/shardBroadcast') 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 = [ const STREAMS = [
{
id: 'news.post',
label: 'News posts',
description: 'New news / Five-on-Friday / newsletter posts.',
personal: false,
requiresLinkedAccount: false,
},
{ {
id: 'server.status', id: 'server.status',
label: 'Server up / down', label: 'Server up / down',
@@ -79,8 +76,10 @@ const STREAMS = [
}, },
] ]
const STREAM_IDS = new Set(STREAMS.map((s) => s.id)) // The owner-keyed subset, needed by mapShardEvent's public-safety filter below.
const isValidStream = (id) => STREAM_IDS.has(id) // 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)) 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 // 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 // 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 // owner-keyed split). This guarantees a sensitive kind can never leak publicly
// even if a future mapping case is added carelessly. // 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))) 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 }

View File

@@ -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 { query } = require('../../utils/db')
const COLS = const COLS = 'id, post_id, status, created_at, updated_at'
'id, post_id, status, ' + const LEG_COLS = 'job_id, leg, status, attempts, last_error, next_attempt_at'
'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'
// Whitelist so a `leg` value can be interpolated into a column name safely — it async function legsFor(jobIds) {
// never comes from raw user input, but keep the guard explicit. if (jobIds.length === 0) return new Map()
const LEGS = ['towncrier', 'discord'] const marks = jobIds.map(() => '?').join(', ')
function assertLeg(leg) { const rows = await query(
if (!LEGS.includes(leg)) throw new Error(`unknown announce leg: ${leg}`) `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]) 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) { async function findById(id) {
const rows = await query(`SELECT ${COLS} FROM announce_jobs WHERE id = ? LIMIT 1`, [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) { 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`, `SELECT ${COLS} FROM announce_jobs WHERE post_id = ? ORDER BY id DESC LIMIT 1`,
[postId], [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 // 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) { async function findDue(now = new Date(), limit = 25) {
return query( const rows = await query(
`SELECT ${COLS} FROM announce_jobs `SELECT ${COLS} FROM announce_jobs j
WHERE (towncrier_status = 'pending' WHERE EXISTS (
AND (towncrier_next_attempt_at IS NULL OR towncrier_next_attempt_at <= ?)) SELECT 1 FROM announce_job_legs l
OR (discord_status = 'pending' WHERE l.job_id = j.id
AND (discord_next_attempt_at IS NULL OR discord_next_attempt_at <= ?)) AND l.status = 'pending'
ORDER BY id ASC AND (l.next_attempt_at IS NULL OR l.next_attempt_at <= ?))
ORDER BY j.id ASC
LIMIT ?`, LIMIT ?`,
[now, now, limit], [now, limit],
) )
return attachLegs(rows)
} }
// Update one leg's columns. `fields` uses leg-agnostic keys (status, attempts, // Update one leg's row. `leg` is a bound VALUE, not an interpolated column name —
// lastError, nextAttemptAt); we map them onto the leg-prefixed columns. // the reason the old leg allowlist that guarded that interpolation is gone. A
async function updateLeg(id, leg, { status, attempts, lastError, nextAttemptAt }) { // module's leg id could not have passed it anyway.
assertLeg(leg) async function updateLeg(jobId, leg, { status, attempts, lastError, nextAttemptAt }) {
await query( await query(
`UPDATE announce_jobs SET `UPDATE announce_job_legs SET
${leg}_status = ?, status = ?, attempts = ?, last_error = ?, next_attempt_at = ?
${leg}_attempts = ?, WHERE job_id = ? AND leg = ?`,
${leg}_last_error = ?, [status, attempts, lastError ?? null, nextAttemptAt ?? null, jobId, leg],
${leg}_next_attempt_at = ?
WHERE id = ?`,
[status, attempts, lastError ?? null, nextAttemptAt ?? null, id],
) )
} }
@@ -65,4 +109,4 @@ async function setStatus(id, status) {
await query('UPDATE announce_jobs SET status = ? WHERE id = ?', [status, id]) 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 }

View File

@@ -1,87 +1,38 @@
// ── Announcement pipeline: pure logic ────────────────────────────────────── // ── Announcement pipeline: pure logic ──────────────────────────────────────
// //
// No DB, no network — just the decisions the worker and model make, kept here so // No DB, no network — just the LEG-AGNOSTIC decisions the worker and model make,
// they are unit-testable in isolation (server/test/announceJobs.test.js): // 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
// • scheduleAfter — exponential backoff schedule + the attempt cap // • scheduleAfter — exponential backoff schedule + the attempt cap
// • rollupStatus — derive the parent job status from the two legs // • rollupStatus — derive the parent job status from its legs
// • legError — squeeze a client result into one error line
const { deriveExcerpt } = require('../../utils/sanitizeHtml') // • baseUrl / articleUrl — the public link an announcement carries
//
// Sidecar town-crier caps, mirrored from the admin route validation // What used to be here and is not any more: `buildTownCrierText`,
// (admin/uoLink.router.js: lines isArray({ max: 8 }), lines.* isLength({ max: 200 })). // `classifyTownCrier` and `classifyDiscord`. A leg's own text-building and result
// We pre-truncate to these so a published post never bounces with towncrier.error. // classification belong to the leg, and a leg is a registration now
const MAX_LINES = 8 // (MODULE_SYSTEM.md §1.8) — they live in utils/shardAnnounce.js and
const MAX_LINE_LEN = 200 // utils/discordAnnounce.js. This file is what every leg shares.
// Backoff between retries, indexed by attempts-so-far. Six attempts spread over // 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 // ~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 BACKOFF_MS = [30_000, 120_000, 600_000, 1_800_000, 3_600_000, 7_200_000]
const MAX_ATTEMPTS = BACKOFF_MS.length const MAX_ATTEMPTS = BACKOFF_MS.length
// Trim to a hard length, appending an ellipsis only when something was cut. // The site's public base, used to build the link an announcement carries.
function clamp(value, max) { function baseUrl() {
const s = String(value == null ? '' : value) return (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '')
.replace(/\s+/g, ' ')
.trim()
if (s.length <= max) return s
return `${s.slice(0, max - 1).trimEnd()}…`
} }
// The public link that goes in the announcement. News has no per-post route // 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 // (App.jsx only has the /site/news list), so we link the list — matches the
// pre-pipeline Discord announce behavior. // pre-pipeline Discord announce behavior.
function articleUrl(baseUrl) { function articleUrl(base) {
return `${String(baseUrl || '').replace(/\/+$/, '')}/site/news` return `${String(base || '').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) }
} }
// 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) { function legError(result) {
if (!result) return 'no response' if (!result) return 'no response'
if (result.status) { if (result.status) {
@@ -99,29 +50,32 @@ function scheduleAfter(attempts) {
return BACKOFF_MS[Math.min(attempts - 1, BACKOFF_MS.length - 1)] return BACKOFF_MS[Math.min(attempts - 1, BACKOFF_MS.length - 1)]
} }
// Parent job status derived from the two leg statuses: // Parent job status derived from its leg statuses:
// done — both legs delivered // done — every leg delivered
// failed — both legs gave up // failed — every leg gave up
// partial — at least one leg reached a terminal state while the other has not // partial — at least one leg reached a terminal state without all of them
// matched it (still pending/retrying, or the opposite terminal state) // agreeing (some still pending/retrying, or a mix of done and failed)
// pending — neither leg is terminal yet // pending — no leg is terminal yet
function rollupStatus(towncrierStatus, discordStatus) { //
if (towncrierStatus === 'done' && discordStatus === 'done') return 'done' // Takes the list of leg statuses rather than two named arguments, because the leg
if (towncrierStatus === 'failed' && discordStatus === 'failed') return 'failed' // 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' 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' return 'pending'
} }
module.exports = { module.exports = {
MAX_LINES,
MAX_LINE_LEN,
MAX_ATTEMPTS, MAX_ATTEMPTS,
BACKOFF_MS, BACKOFF_MS,
buildTownCrierText, baseUrl,
articleUrl, articleUrl,
classifyTownCrier, legError,
classifyDiscord,
scheduleAfter, scheduleAfter,
rollupStatus, rollupStatus,
} }

View File

@@ -2,19 +2,21 @@
// //
// Sits between the DB rows and the worker: creates jobs on publish, records each // 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 // 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. // announced_at when every leg lands, and resets a leg for the admin retry button.
// The pure decisions (backoff, rollup, classification) live in .logic.js. // 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 db = require('./announceJobs.db')
const logic = require('./announceJobs.logic') const logic = require('./announceJobs.logic')
const registries = require('../../modules/registries')
const posts = require('../posts/posts.model') const posts = require('../posts/posts.model')
const log = require('../../utils/logger')('announce') const log = require('../../utils/logger')('announce')
// Enqueue an announcement for a freshly-published news post: one job row (both // Enqueue an announcement for a freshly-published news post: one job row, one leg
// legs pending, due immediately) plus a back-pointer on the post so the admin // row per registered leg (all pending, due immediately), plus a back-pointer on
// panel can find it. Returns the new job id. // the post so the admin panel can find it. Returns the new job id.
async function enqueue(postId) { async function enqueue(postId) {
const jobId = await db.create(postId) const jobId = await db.create(postId, registries.announceLegIds())
await posts.linkAnnounceJob(postId, jobId) await posts.linkAnnounceJob(postId, jobId)
log.info('announce job enqueued', { jobId, postId }) log.info('announce job enqueued', { jobId, postId })
return jobId 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 // Record a leg's dispatch outcome and refresh the rollup. `outcome` is one of a
// logic.classify*'s results: 'done' | 'retry' | 'terminal'. For 'retry' we bump // leg's classify() results: 'done' | 'retry' | 'terminal'. For 'retry' we bump the
// the attempt count and schedule the next run (or fail the leg once the cap is // attempt count and schedule the next run (or fail the leg once the cap is hit).
// hit). Returns the updated job row. // Returns the updated job row.
async function recordOutcome(job, leg, { outcome, error }) { 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') { if (outcome === 'done') {
await db.updateLeg(job.id, leg, { status: 'done', attempts, lastError: null, nextAttemptAt: null }) 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) return refreshStatus(job.id)
} }
// Recompute and persist the parent status from the two legs; stamp the post's // Recompute and persist the parent status from the legs; stamp the post's
// announced_at the moment both legs have delivered. // announced_at the moment every leg has delivered.
async function refreshStatus(jobId) { async function refreshStatus(jobId) {
const job = await db.findById(jobId) const job = await db.findById(jobId)
if (!job) return null 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) if (status !== job.status) await db.setStatus(jobId, status)
job.status = status job.status = status
if (status === 'done') { 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 // 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. // after a config fix gets a full budget again.
async function resetLeg(postId, leg) { 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) const job = await db.findByPostId(postId)
if (!job) return null 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 }) 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 }) 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) { async function getByPostId(postId) {
return db.findByPostId(postId) return withLabels(await db.findByPostId(postId))
} }
module.exports = { module.exports = {
@@ -113,4 +133,5 @@ module.exports = {
refreshStatus, refreshStatus,
resetLeg, resetLeg,
getByPostId, getByPostId,
withLabels,
} }

View 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 }

View 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,
}

View File

@@ -1,8 +1,10 @@
// Per-user push-notification subscriptions (which streams a user opted into; // 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 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) const getForUser = async (userId) => (await db.listByUser(userId)).map((r) => r.stream_id)

View File

@@ -1,4 +1,4 @@
const { pool, query } = require('../../core') const { pool, query } = require('../../utils/db')
// Raw SQL for the spawn atlas. Every table here is IMPORT-OWNED: `replaceAtlas` // Raw SQL for the spawn atlas. Every table here is IMPORT-OWNED: `replaceAtlas`
// empties and refills all six inside one transaction, and nothing else in the // empties and refills all six inside one transaction, and nothing else in the

View File

@@ -2,7 +2,7 @@ const fs = require('fs')
const path = require('path') const path = require('path')
const db = require('./shardAtlas.db') const db = require('./shardAtlas.db')
const { settings } = require('../../core') const settings = require('../settings/settings.model')
const { slugify } = require('../../utils/spawnAtlasParse') const { slugify } = require('../../utils/spawnAtlasParse')
const { const {
AtlasSourceError, AtlasSourceError,
@@ -11,7 +11,7 @@ const {
hashSources, hashSources,
sameSources, sameSources,
} = require('../../utils/spawnAtlasSource') } = require('../../utils/spawnAtlasSource')
const log = require('../../core').logger('atlas') const log = require('../../utils/logger')('shardAtlas')
// The spawn atlas, refreshed from the shard's own ServUO tree. // The spawn atlas, refreshed from the shard's own ServUO tree.
// //

View 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 }

View File

@@ -1,9 +1,7 @@
// ── The module loader ────────────────────────────────────────────────────── // ── The module loader ──────────────────────────────────────────────────────
// //
// SPIKE (docs/website/MODULE_SYSTEM.md §2.7 Phase 1). This is the smallest // Phase 2, PR 2 of docs/website/MODULE_SYSTEM.md §2.7. The normative contract is
// loader that can carry /api/v1/public/atlas/* out of core and prove the // docs/website/MODULE_API.md Part 4; where the two disagree, the contract wins.
// contract in docs/website/MODULE_API.md. Phase 2 rebuilds it properly with the
// installed_modules table, the full state machine and the admin panel behind it.
// //
// The one property this file exists to guarantee, and the reason it looks the // The one property this file exists to guarantee, and the reason it looks the
// way it does: // way it does:
@@ -12,18 +10,37 @@
// SYNCHRONOUS.** `scripts/routeManifest.js:38` and `swagger/swagger.js:29` // 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 // 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 // awaited a database row before mounting would make every module route
// invisible to the frozen-URL-surface test (§1.12). So: readdirSync at // invisible to the frozen-URL-surface test (§1.12). So: readdirSync at require
// require time, no database, no promises. // time, no database, no promises (§4.1).
// //
// A module that fails ANYWHERE in this file fails alone. Nothing here may throw // 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 // past its own try/catch — a bad module must cost the site its routes, never its
// boot. // 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 fs = require('fs')
const path = require('path') const path = require('path')
const { MODULE_API_VERSION } = require('./version') const { MODULE_API_VERSION } = require('./version')
const semver = require('./semver') const semver = require('./semver')
const registries = require('./registries')
const { splitStatements } = require('../utils/sqlStatements')
const log = require('../utils/logger')('modules') const log = require('../utils/logger')('modules')
@@ -41,35 +58,26 @@ const MANIFEST_KEYS = new Set([
'schema', 'purge', 'mounts', 'extensions', 'capabilities', 'schema', 'purge', 'mounts', 'extensions', 'capabilities',
]) ])
// Prefixes core itself owns, per tier. A module may not take one of these. // Extension slots are declared by core, at require time, in the router that owns
// Hardcoded for the spike; Phase 2 derives it from the tier mount tables so it // the resource (registries.declareSlot). The loader asks the registry which exist
// cannot drift the first time core adds a capability router. // rather than keeping a list, for the same reason the prefix check probes the
const CORE_PREFIXES = { // live tier routers: a second copy of the answer is a copy that drifts.
public: ['/posts', '/wiki', '/pages', '/shard'],
admin: [
'/account', '/users', '/invites', '/auth', '/moderation', '/bot-activity',
'/activity', '/posts', '/uploads', '/wiki', '/pages', '/shard', '/uo-link',
'/email', '/discord-bot', '/settings',
],
player: ['/account', '/shard', '/appeals'],
}
// id → record. Populated by scan(), read by mountInto/boot/shutdown/list. // id → record. Populated by load(), read by list().
const modules = new Map() const modules = new Map()
let scanned = false let loaded = false
// ── ctx ──────────────────────────────────────────────────────────────────── // ── ctx ────────────────────────────────────────────────────────────────────
// Everything a module may reach in core, and nothing else (MODULE_API.md §2.3). // Everything a module may reach in core, and nothing else (§2.3). Required
// Required lazily inside the factory rather than at file scope: this module is // lazily inside the factory rather than at file scope: this file is required by
// required by app.js, and hoisting these to the top would make the DB pool, the // app.js, and hoisting these to the top would make the DB pool, the settings
// settings model and the upload directory startup-time dependencies of the // model and the upload directory startup-time dependencies of the loader itself.
// loader itself.
function buildCtx(id, moduleRoot) { function buildCtx(id, moduleRoot) {
/* eslint-disable global-require */ /* eslint-disable global-require */
// The shared SERVER dependencies — the exact counterpart of window.__rg's // 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 // react/react-dom/react-router on the client, and load-bearing for the same
// two reasons. // two reasons (§7.2).
// //
// 1. A module lives at <repo>/modules/<id>/, OUTSIDE server/, so Node's // 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 // resolver walks up from there and never sees server/node_modules. A
@@ -97,9 +105,9 @@ function buildCtx(id, moduleRoot) {
const uploads = require('../router/v1/admin/imageUpload') const uploads = require('../router/v1/admin/imageUpload')
/* eslint-enable global-require */ /* eslint-enable global-require */
// Narrowed on purpose (MODULE_API.md §2.3): utils/auth also re-exports // Narrowed on purpose (§2.3): utils/auth also re-exports signToken,
// signToken/setAuthCookie/the TOTP challenge primitives, and minting a session // setAuthCookie and the TOTP challenge primitives, and minting a session is
// is core's job. A module that needs an identity needs to READ one. // core's job. A module that needs an identity needs to READ one.
const ctx = { const ctx = {
moduleId: id, moduleId: id,
paths: { moduleRoot }, paths: { moduleRoot },
@@ -143,6 +151,11 @@ function buildApi(record) {
if (record.called.has(name)) throw new Error(`${name}() called twice`) if (record.called.has(name)) throw new Error(`${name}() called twice`)
record.called.add(name) 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 { return {
registerRoutes(mounts) { registerRoutes(mounts) {
once('registerRoutes') once('registerRoutes')
@@ -156,26 +169,50 @@ function buildApi(record) {
} }
} }
}, },
// Declared for contract completeness; the spike registers none of these, and // The three de-entanglement registries (§2.4). They live in registries.js
// an accepting no-op would let a module think it had registered something. // rather than here because core registers through the same staging area, and
registerExtension() { throw new Error('registerExtension: not implemented in the Phase 1 spike') }, // core has no `api` object.
registerNotificationStreams() { throw new Error('registerNotificationStreams: not implemented in the Phase 1 spike') }, //
registerAnnounceLeg() { throw new Error('registerAnnounceLeg: not implemented in the Phase 1 spike') }, // These STAGE. Nothing a module registers is visible to core until the
onBoot(fn) { // second pass commits it, for the reason the second pass exists at all: a
once('onBoot') // module that throws halfway through register(), or fails checkDeclared
if (typeof fn !== 'function') throw new Error('onBoot: expected a function') // after it, must leave nothing behind. A half-registered stream catalog
record.onBoot = fn // would be worse than a missing one — it would be a subscribable stream
}, // nothing will ever publish to.
onShutdown(fn) { registerExtension: record.staged.registerExtension,
once('onShutdown') registerNotificationStreams(streams) {
if (typeof fn !== 'function') throw new Error('onShutdown: expected a function') once('registerNotificationStreams')
record.onShutdown = fn 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 ───────────────────────────────────────────────────────────── // ── 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. // 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 // module-uo's twenty-seven tables predate the module system by two years, and
@@ -202,68 +239,196 @@ function coreTableNames() {
return coreTables return coreTables
} }
function checkTableNames(dir, manifest) { // The only leading verbs a fragment may use — an allowlist, not a DROP denylist.
const file = path.join(dir, manifest.schema) //
const sql = fs.readFileSync(file, 'utf8') // §2.6 bans `DROP`, but a denylist only ever bans what somebody thought of, and
const allowed = LEGACY_TABLE_PREFIXES[manifest.id] || [] // core's own schema.sql needs exactly four verbs: CREATE, ALTER, INSERT, UPDATE.
const core = coreTableNames() // 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'])
for (const m of sql.matchAll(CREATE_TABLE)) { const CREATE_TABLE_ANY = /^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?/i
const table = m[1].toLowerCase() const CREATE_TABLE_GUARDED = /^CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+/i
if (core.has(table)) throw new Error(`schema fragment declares core table "${table}"`)
for (const other of modules.values()) {
if (other.tables && other.tables.has(table)) {
throw new Error(`schema fragment declares "${table}", already owned by module "${other.id}"`)
}
}
const prefixed = table.startsWith(`${manifest.id}_`) || allowed.some((p) => table.startsWith(p))
if (!prefixed) {
throw new Error(`schema fragment table "${table}" is not prefixed "${manifest.id}_"`)
}
}
}
/** Record which tables a module owns, so the next module can be checked against it. */ /**
* 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) { function tablesOf(dir, manifest) {
if (!manifest.schema) return new Set() if (!manifest.schema) return new Set()
const sql = fs.readFileSync(path.join(dir, manifest.schema), 'utf8') 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())) return new Set([...sql.matchAll(CREATE_TABLE)].map((m) => m[1].toLowerCase()))
} }
function readManifest(dir, id) { 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 file = path.join(dir, 'module.json')
const manifest = JSON.parse(fs.readFileSync(file, 'utf8')) const manifest = JSON.parse(fs.readFileSync(file, 'utf8'))
for (const key of Object.keys(manifest)) { for (const key of Object.keys(manifest)) {
// Rejected, not ignored: a typo'd key must be a loud failure rather than a // Rejected, not ignored: a typo'd key must be a loud failure rather than a
// silently inert setting the operator believes they configured. // silently inert setting the operator believes they configured.
if (!MANIFEST_KEYS.has(key)) throw new Error(`unknown key "${key}" in module.json`) if (!MANIFEST_KEYS.has(key)) fail('manifest', `unknown key "${key}" in module.json`)
} }
if (!ID.test(manifest.id || '')) throw new Error(`invalid id "${manifest.id}"`) if (!ID.test(manifest.id || '')) fail('manifest', `invalid id "${manifest.id}"`)
if (manifest.id !== id) throw new Error(`id "${manifest.id}" does not match directory "${id}"`) if (manifest.id !== id) fail('manifest', `id "${manifest.id}" does not match directory "${id}"`)
if (!manifest.version) throw new Error('missing version') if (!manifest.version) fail('manifest', 'missing version')
if (!manifest.coreApi) throw new Error('missing coreApi') if (!manifest.coreApi) fail('core_api', 'missing coreApi')
if (!semver.satisfies(MODULE_API_VERSION, manifest.coreApi)) { if (!semver.satisfies(MODULE_API_VERSION, manifest.coreApi)) {
throw new Error(`needs core API ${manifest.coreApi}, this core is ${MODULE_API_VERSION}`) fail('core_api', `needs core API ${manifest.coreApi}, this core is ${MODULE_API_VERSION}`)
} }
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.
throw new Error('declares schema but no purge')
}
if (manifest.schema) checkTableNames(dir, manifest)
for (const [tier, prefixes] of Object.entries(manifest.mounts || {})) { for (const [tier, prefixes] of Object.entries(manifest.mounts || {})) {
if (!TIERS.includes(tier)) throw new Error(`unknown tier "${tier}" in mounts`) if (!TIERS.includes(tier)) fail('mounts', `unknown tier "${tier}" in mounts`)
for (const prefix of prefixes) { for (const prefix of prefixes) {
if (!PREFIX.test(prefix)) throw new Error(`bad prefix "${prefix}" in mounts.${tier}`) if (!PREFIX.test(prefix)) fail('mounts', `bad prefix "${prefix}" in mounts.${tier}`)
if (CORE_PREFIXES[tier].includes(prefix)) throw new Error(`prefix ${tier}${prefix} is owned by core`) if (ownedByCore(tierRouters[tier], prefix)) {
fail('mounts', `prefix ${tier}${prefix} is owned by core`)
}
for (const other of modules.values()) { for (const other of modules.values()) {
if ((other.manifest.mounts?.[tier] || []).includes(prefix)) { if ((other.manifest.mounts?.[tier] || []).includes(prefix)) {
throw new Error(`prefix ${tier}${prefix} already registered by module "${other.id}"`) 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 return manifest
} }
@@ -278,16 +443,35 @@ function checkDeclared(record) {
} }
} }
// ── Scan ─────────────────────────────────────────────────────────────────── // ── Load ───────────────────────────────────────────────────────────────────
/** /**
* Discover, validate and register every module under MODULES_DIR. Synchronous, * Discover, validate, register and mount every module under MODULES_DIR.
* filesystem-only, and safe to call when the directory does not exist. Called *
* once from app.js at require time; a second call is a no-op. * **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 scan() { function load(tierRouters) {
if (scanned) return if (loaded) return
scanned = true 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 = [] let entries = []
try { try {
@@ -309,23 +493,39 @@ function scan() {
dir, dir,
manifest: null, manifest: null,
routes: { public: new Map(), admin: new Map(), player: new Map() }, routes: { public: new Map(), admin: new Map(), player: new Map() },
staged: registries.stage(id),
tables: new Set(), tables: new Set(),
called: new Set(), called: new Set(),
onBoot: null, hooks: { onBoot: null, onShutdown: null },
onShutdown: null, client: null,
ctx: null,
state: 'installed', state: 'installed',
stage: null,
reason: 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 { try {
record.manifest = readManifest(dir, id) record.manifest = readManifest(dir, id, tierRouters)
record.client = resolveClient(dir, id, record.manifest)
stage = 'schema'
record.tables = tablesOf(dir, record.manifest) record.tables = tablesOf(dir, record.manifest)
checkTableNames(id, record.tables)
if (record.manifest.server) { if (record.manifest.server) {
const entry = path.join(dir, record.manifest.server) const entry = path.join(dir, record.manifest.server)
stage = 'require'
// eslint-disable-next-line global-require, import/no-dynamic-require // eslint-disable-next-line global-require, import/no-dynamic-require
const register = require(entry) const register = require(entry)
if (typeof register !== 'function') throw new Error(`${record.manifest.server} does not export a function`) if (typeof register !== 'function') throw new Error(`${record.manifest.server} does not export a function`)
register(buildCtx(id, dir), buildApi(record)) 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) checkDeclared(record)
} }
record.state = 'registered' record.state = 'registered'
@@ -337,159 +537,261 @@ function scan() {
// A failure here is BEFORE any route was mounted, so this module's routes // 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). // and nav are simply absent and the site comes up without it (§4.4).
record.state = 'startup_failed' record.state = 'startup_failed'
record.stage = err.stage || stage
record.reason = err.message record.reason = err.message
record.manifest = record.manifest || { id, version: 'unknown' } record.manifest = record.manifest || { id, version: 'unknown' }
modules.set(id, record) modules.set(id, record)
log.error(`module "${id}" failed to load — continuing without it`, { reason: err.message }) log.error(`module "${id}" failed to load — continuing without it`, {
} stage: record.stage,
}
}
// ── Mounting ───────────────────────────────────────────────────────────────
/**
* Mount every registered module's routers for one tier onto that tier's router.
* Called from router/v1/{public,admin,player}/index.js, after core's own mounts
* so a module can never shadow a core prefix even if the collision check above
* were somehow bypassed.
*/
function mountInto(tier, tierRouter) {
scan()
for (const record of modules.values()) {
if (record.state !== 'registered' && record.state !== 'started') continue
for (const [prefix, router] of record.routes[tier]) {
// The dispatch guard. A module that failed AFTER mounting (schema replay,
// onBoot) keeps its URLs — so routes.manifest.json does not depend on
// whether a boot hook happened to succeed on the generating machine — but
// answers 503 rather than serving half-initialised data (§4.4).
tierRouter.use(prefix, (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()
}, router)
}
}
}
// ── Lifecycle ──────────────────────────────────────────────────────────────
/** Read every registered module's schema fragment, in scan order. */
function schemaFragments() {
scan()
const out = []
for (const record of modules.values()) {
if (record.state !== 'registered' || !record.manifest.schema) continue
const file = path.join(record.dir, record.manifest.schema)
try {
out.push({ id: record.id, sql: fs.readFileSync(file, 'utf8') })
} catch (err) {
markFailed(record.id, `schema fragment unreadable: ${err.message}`)
log.error(`module "${record.id}" schema fragment unreadable`, { reason: err.message })
}
}
return out
}
/**
* Move a module to `startup_failed` with a reason. Called by whoever ran the
* step that failed — ensureSchema() replays the fragments, so it is the only
* thing that can know a fragment threw.
*
* Failing here is a POST-mount failure: the routes stay mounted and the dispatch
* guard turns them into 503s, which is what keeps routes.manifest.json
* independent of whether a boot step succeeded on the generating machine (§4.4).
*/
function markFailed(id, reason) {
const record = modules.get(id)
if (!record) return
record.state = 'startup_failed'
record.reason = reason
}
/**
* Run every registered module's onBoot. Called from server.js AFTER
* ensureSchema() and seedDefaults() (so a module's own tables exist) and BEFORE
* the listener binds. Individually try/caught: a hook that throws costs that
* module its `started` state and nothing else.
*/
async function boot() {
scan()
for (const record of modules.values()) {
if (record.state !== 'registered') continue
try {
if (record.onBoot) await record.onBoot(buildCtx(record.id, record.dir))
record.state = 'started'
log.info(`module "${record.id}" started`)
} catch (err) {
record.state = 'startup_failed'
record.reason = `onBoot: ${err.message}`
log.error(`module "${record.id}" onBoot failed — its routes will answer 503`, {
reason: err.message, 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)
}
} }
const SHUTDOWN_BUDGET_MS = 5000 /**
* Mount one module's routers onto the tier routers, behind the dispatch guard.
/** Run onShutdown in reverse registration order, bounded, never throwing. */ *
async function shutdown() { * The guard is the other half of §4.4. A module that fails BEFORE this point has
const records = [...modules.values()].reverse() * no routes at all; one that fails after — schema replay (PR 3), `onBoot`
for (const record of records) { * (PR 5) — keeps its URLs and answers 503, so `routes.manifest.json` never
if (record.state !== 'started' || !record.onShutdown) continue * depends on whether a boot hook happened to succeed on the machine that
try { * generated it. `disabled` is 404 and unreachable until PR 5 wires
await Promise.race([ * `installed_modules` in; it is written here because the guard is the contract's
record.onShutdown(), * §4.5, not a later addition.
new Promise((_, reject) => */
setTimeout(() => reject(new Error('timed out')), SHUTDOWN_BUDGET_MS).unref()), function mount(record, tierRouters) {
]) for (const tier of TIERS) {
} catch (err) { for (const [prefix, router] of record.routes[tier]) {
log.warn(`module "${record.id}" onShutdown failed`, { reason: err.message }) tierRouters[tier].use(prefix, stateGuard(record), router)
} }
} }
} }
// ── Introspection ────────────────────────────────────────────────────────── /**
* 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'])
/** /**
* What GET /api/v1/public/modules, the HTML shell and the admin panel read. * Move a loaded module to a new state — the POST-mount transitions.
* *
* `clientDir` and `entryUrl` are split deliberately: app.js needs the absolute * Called by whoever ran the step that failed or the step that succeeded, because
* directory to serve statically, and it must be the DIST directory rather than * only they can know: `ensureSchema()` replays the fragments (PR 3), and
* the module root — a module keeps its server source, its module.json and its * lifecycle.js runs `onBoot` and reconciles `installed_modules` (whose `disabled`
* schema fragment alongside the client build, and one static mount over the * rows are what make the guard's 404 leg reachable).
* module root would publish all of them. *
* 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() { function list() {
scan() assertLoaded('list')
return [...modules.values()].map((r) => { return [...modules.values()].map((r) => ({
const entry = r.manifest.client && r.manifest.client.entry id: r.id,
return { name: r.manifest.name || r.id,
id: r.id, version: r.manifest.version,
name: r.manifest.name || r.id, state: r.state,
version: r.manifest.version, stage: r.stage,
state: r.state, reason: r.reason,
reason: r.reason, capabilities: r.manifest.capabilities || [],
capabilities: r.manifest.capabilities || [], }))
// e.g. entry "client/dist/entry.js" → dir <root>/client/dist, url /modules/uo/entry.js }
clientDir: entry ? path.join(r.dir, path.dirname(entry)) : null,
entryUrl: entry ? `/modules/${r.id}/${path.basename(entry)}` : null, /**
} * 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. */ /** Absolute path of the modules directory. */
const dir = () => MODULES_DIR const dir = () => MODULES_DIR
// Test seam: the scan is memoised, and a test that points MODULES_DIR somewhere
// else needs to be able to redo it.
function _reset() {
modules.clear()
scanned = false
}
module.exports = { module.exports = {
scan, mountInto, schemaFragments, markFailed, boot, shutdown, list, dir, _reset, MODULES_DIR, load,
list,
setState,
fragments,
bootable,
shutdownHooks,
clientChunks,
clientEntryUrls,
isLoaded,
dir,
} }

View 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,
}

View 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 }

View File

@@ -728,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) { async function createUser(req, res) {
try { try {
if (await users.getRawByUsername(req.body.username)) { if (await users.getRawByUsername(req.body.username)) {
@@ -938,6 +955,7 @@ module.exports = {
ASSET_RULES, ASSET_RULES,
listActivity, listActivity,
listUsers, listUsers,
getUser,
createUser, createUser,
updateUser, updateUser,
deleteUser, deleteUser,

View File

@@ -15,7 +15,6 @@ const express = require('express')
const { isLoggedIn, requireRole } = require('../../../utils/auth') const { isLoggedIn, requireRole } = require('../../../utils/auth')
const noindex = require('../../../middleware/noindex') const noindex = require('../../../middleware/noindex')
const modules = require('../../../modules/loader')
const accountRouter = require('./account.router') const accountRouter = require('./account.router')
const usersRouter = require('./users.router') const usersRouter = require('./users.router')
@@ -75,11 +74,6 @@ adminRouter.use('/email', emailRouter)
adminRouter.use('/discord-bot', discordBotRouter) adminRouter.use('/discord-bot', discordBotRouter)
adminRouter.use('/settings', settingsRouter) adminRouter.use('/settings', settingsRouter)
// Installed modules' admin routers. Already behind this group's
// noindex/isLoggedIn/staffOnly gate — a module adds per-route gates on top and
// never re-implements the tier gate (docs/website/MODULE_API.md §2.4).
modules.mountInto('admin', adminRouter)
// The two singletons that own no path segment of their own: GET /dashboard and // The two singletons that own no path segment of their own: GET /dashboard and
// PUT /site-mode. Mounted at the group root, last, exactly where the residual // PUT /site-mode. Mounted at the group root, last, exactly where the residual
// admin.routes.js used to sit — safe because dashboard.router.js declares no // admin.routes.js used to sit — safe because dashboard.router.js declares no

View File

@@ -1,5 +1,5 @@
// Admin · Posts — news, five-on-friday, newsletter and screenshot posts, plus // 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 // Mounted at /api/v1/admin/posts by admin/index.js, which already applied
// `noindex, isLoggedIn, staffOnly`. No extra gate: managing content is the // `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 ctrl = require('./admin.controller')
const { upload } = require('./imageUpload') const { upload } = require('./imageUpload')
const validate = require('../../../middleware/validate') const validate = require('../../../middleware/validate')
const registries = require('../../../modules/registries')
const postsRouter = express.Router() const postsRouter = express.Router()
@@ -124,14 +125,17 @@ postsRouter.get(
postsRouter.post( postsRouter.post(
'/:id/announce/retry', '/:id/announce/retry',
// #swagger.tags = ['Admin · Posts'] // #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.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Post id.' } // #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[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" } } } } */ /* #swagger.responses[404] = { description: 'No announcement job for this post', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(), 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, validate,
ctrl.retryAnnounceLeg, ctrl.retryAnnounceLeg,
) )

View File

@@ -15,17 +15,7 @@
// admin needs to be told what is wrong with their path, and a 500 says only // admin needs to be told what is wrong with their path, and a 500 says only
// "something broke". // "something broke".
// ⚠ SPIKE ARTIFACT — core reaching INTO a module. Phase 1 carries only the const atlas = require('../../../model/shardAtlas/shardAtlas.model')
// PUBLIC atlas routes out of core (MODULE_SYSTEM.md §2.7); these five admin
// routes live at /admin/shard/atlas/*, inside the `/shard` prefix that core
// still owns, so the module cannot take them without either colliding with core
// or changing a URL — and routes.manifest.json must not move.
//
// So this one import crosses the boundary in the core → module direction. It is
// not the direction the zero-imports rule forbids (a module must not reach into
// core), but it is still wrong, and it is precisely what Phase 3 fixes by moving
// the whole `/shard` admin prefix at once. Recorded here rather than hidden.
const atlas = require('../../../../../modules/uo/server/model/shardAtlas/shardAtlas.model')
const activity = require('../../../model/activity/activity.model') const activity = require('../../../model/activity/activity.model')
const log = require('../../../utils/logger')('admin-shard-atlas') const log = require('../../../utils/logger')('admin-shard-atlas')

View File

@@ -4,20 +4,18 @@
// `noindex, isLoggedIn, staffOnly`. The whole capability is admin-only: editors // `noindex, isLoggedIn, staffOnly`. The whole capability is admin-only: editors
// and moderators manage content and reports, never accounts. // and moderators manage content and reports, never accounts.
// //
// Handlers still live in admin.controller.js (users) and usersShard.controller.js // Handlers live in admin.controller.js. The shard footprint that used to be
// (uo-link footprint); this PR re-wires routes, not logic. // wired here is now an EXTENSION SLOT (MODULE_SYSTEM.md §1.9) — see the bottom of
// this file.
const express = require('express') const express = require('express')
const { body, param } = require('express-validator') const { body, param } = require('express-validator')
const ctrl = require('./admin.controller') const ctrl = require('./admin.controller')
const usersShard = require('./usersShard.controller') const registries = require('../../../modules/registries')
const { requireRole } = require('../../../utils/auth') const { requireRole } = require('../../../utils/auth')
const validate = require('../../../middleware/validate') const validate = require('../../../middleware/validate')
// Same shape the shard routes validate account names with.
const SHARD_ACCOUNT_RE = /^[A-Za-z0-9_.-]{1,120}$/
const usersRouter = express.Router() const usersRouter = express.Router()
const adminOnly = requireRole('admin') const adminOnly = requireRole('admin')
@@ -151,11 +149,6 @@ usersRouter.post(
ctrl.resetUserMfa, ctrl.resetUserMfa,
) )
// ── User → shard (uo-link) footprint (admin only) ─────────────────────
// Backs the /admin/users/:id detail page: a user's linked game accounts and,
// scoped to those accounts, their vendor sales / houses / online characters.
// Live character rosters are fetched by the client through /admin/shard/* (which
// already grants admins a bypass to any account), so no routes for them here.
usersRouter.get( usersRouter.get(
'/:id', '/:id',
// #swagger.tags = ['Admin · Users'] // #swagger.tags = ['Admin · Users']
@@ -166,84 +159,21 @@ usersRouter.get(
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(), param('id').isInt(),
validate, validate,
usersShard.getUser, ctrl.getUser,
)
usersRouter.get(
'/:id/shard/accounts',
// #swagger.tags = ['Admin · Users']
// #swagger.summary = 'A user’s linked game accounts (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
/* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardLink" } } } } } */
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
validate,
usersShard.listAccounts,
)
usersRouter.get(
'/:id/shard/sales',
// #swagger.tags = ['Admin · Users']
// #swagger.summary = 'Recent vendor sales on a user’s accounts (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
validate,
usersShard.getSales,
)
usersRouter.get(
'/:id/shard/houses',
// #swagger.tags = ['Admin · Users']
// #swagger.summary = 'Houses owned by a user’s accounts (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
/* #swagger.responses[200] = { description: 'Houses (IDOC first)', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
validate,
usersShard.getHouses,
)
usersRouter.get(
'/:id/shard/online',
// #swagger.tags = ['Admin · Users']
// #swagger.summary = 'A user’s characters currently online (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
/* #swagger.responses[200] = { description: 'Online characters', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
validate,
usersShard.getOnline,
)
usersRouter.get(
'/:id/shard/standing',
// #swagger.tags = ['Admin · Users']
// #swagger.summary = 'A user’s shard standing — governorships held and guilds led (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
/* #swagger.responses[200] = { description: 'Standing { governorOf, guildsLed }', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
validate,
usersShard.getStanding,
)
usersRouter.delete(
'/:id/shard/link/:account',
// #swagger.tags = ['Admin · Users']
// #swagger.summary = 'Unlink a game account from this user (admin only)'
// #swagger.description = 'Severs a game account’s tie to the website user from the site side (sidecar DELETE /link/{account}) and drops the local mirror. actor is stamped from the session.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
// #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Game account to unlink.' }
/* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, unlinked: { type: "boolean" } } } } } } */
/* #swagger.responses[403] = { description: 'Protected staff account (refused by shard)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'Not linked', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
param('id').isInt(),
param('account').matches(SHARD_ACCOUNT_RE),
validate,
usersShard.unlinkAccount,
) )
// ── The `admin.users.detail` extension slot (MODULE_SYSTEM.md §1.9) ────────
//
// A module may hang routes off this core resource. Core DECLARES the slot; only
// core may, and a module may only fill one (MODULE_API.md §2.4). What fills it
// today is core's own usersShard.router.js, registered in registries.js's
// registerCore() — the shard footprint that used to be wired inline right here.
// Phase 3 changes the registrant, not this line.
//
// LAST, deliberately: every core route on the resource is already declared, so
// first-match-wins means core owns any path conflict. The router is created at
// declare time and filled later, because this file is required while app.js is
// still being built — long before a module has been scanned.
usersRouter.use('/:id', registries.declareSlot('admin.users.detail'))
module.exports = usersRouter module.exports = usersRouter

View File

@@ -25,18 +25,6 @@ async function accountsForUser(id) {
return { user, links, accounts: links.map((l) => l.account) } return { user, links, accounts: links.map((l) => l.account) }
} }
// GET /admin/users/:id — the sanitized user (so the detail page is refresh-safe).
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' })
}
}
// GET /admin/users/:id/shard/accounts — the user's linked game accounts. // GET /admin/users/:id/shard/accounts — the user's linked game accounts.
async function listAccounts(req, res) { async function listAccounts(req, res) {
try { try {
@@ -139,4 +127,4 @@ async function unlinkAccount(req, res) {
} }
} }
module.exports = { getUser, listAccounts, getSales, getHouses, getOnline, getStanding, unlinkAccount } module.exports = { listAccounts, getSales, getHouses, getOnline, getStanding, unlinkAccount }

View File

@@ -0,0 +1,111 @@
// ── The `admin.users.detail` extension slot's contents ─────────────────────
//
// MODULE-UO CONTENT, still living in core. MODULE_SYSTEM.md §1.9 named the
// fourth mount shape: module routes hanging off a CORE resource. These six paths
// are shard reads on `/admin/users/:id`, a user-management URL core owns, so
// they cannot move with a prefix and cannot stay where they are either.
//
// The resolution is an extension SLOT. `users.router.js` declares
// `admin.users.detail` and mounts its router at `/:id`; this file is what fills
// it, registered through modules/registries.js like a module would
// (registerCore() → `api.registerExtension('admin.users.detail', …)`). Phase 3
// moves this file to module-uo and changes nothing else — the six URLs are
// identical either way, and core never learns what "shard" means.
//
// `mergeParams` comes from the slot's router, so `req.params.id` is the parent's
// user id. Core's own routes on the resource are declared BEFORE the slot is
// mounted, so core always wins a path conflict (MODULE_API.md §2.4).
const express = require('express')
const { param } = require('express-validator')
const usersShard = require('./usersShard.controller')
const validate = require('../../../middleware/validate')
// Same shape the shard routes validate account names with.
const SHARD_ACCOUNT_RE = /^[A-Za-z0-9_.-]{1,120}$/
const shardRouter = express.Router({ mergeParams: true })
// Backs the /admin/users/:id detail page: a user's linked game accounts and,
// scoped to those accounts, their vendor sales / houses / online characters.
// Live character rosters are fetched by the client through /admin/shard/* (which
// already grants admins a bypass to any account), so no routes for them here.
shardRouter.get(
'/shard/accounts',
// #swagger.tags = ['Admin · Users']
// #swagger.summary = 'A user’s linked game accounts (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
/* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardLink" } } } } } */
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
validate,
usersShard.listAccounts,
)
shardRouter.get(
'/shard/sales',
// #swagger.tags = ['Admin · Users']
// #swagger.summary = 'Recent vendor sales on a user’s accounts (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
validate,
usersShard.getSales,
)
shardRouter.get(
'/shard/houses',
// #swagger.tags = ['Admin · Users']
// #swagger.summary = 'Houses owned by a user’s accounts (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
/* #swagger.responses[200] = { description: 'Houses (IDOC first)', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
validate,
usersShard.getHouses,
)
shardRouter.get(
'/shard/online',
// #swagger.tags = ['Admin · Users']
// #swagger.summary = 'A user’s characters currently online (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
/* #swagger.responses[200] = { description: 'Online characters', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
validate,
usersShard.getOnline,
)
shardRouter.get(
'/shard/standing',
// #swagger.tags = ['Admin · Users']
// #swagger.summary = 'A user’s shard standing — governorships held and guilds led (admin only)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
/* #swagger.responses[200] = { description: 'Standing { governorOf, guildsLed }', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
validate,
usersShard.getStanding,
)
shardRouter.delete(
'/shard/link/:account',
// #swagger.tags = ['Admin · Users']
// #swagger.summary = 'Unlink a game account from this user (admin only)'
// #swagger.description = 'Severs a game account’s tie to the website user from the site side (sidecar DELETE /link/{account}) and drops the local mirror. actor is stamped from the session.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
// #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Game account to unlink.' }
/* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, unlinked: { type: "boolean" } } } } } } */
/* #swagger.responses[403] = { description: 'Protected staff account (refused by shard)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'Not linked', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
param('account').matches(SHARD_ACCOUNT_RE),
validate,
usersShard.unlinkAccount,
)
module.exports = shardRouter

View File

@@ -5,7 +5,7 @@
const pushDevices = require('../../../model/pushDevices/pushDevices.model') const pushDevices = require('../../../model/pushDevices/pushDevices.model')
const notificationSubs = require('../../../model/notificationSubs/notificationSubs.model') const notificationSubs = require('../../../model/notificationSubs/notificationSubs.model')
const { STREAMS } = require('../../../config/notificationStreams') const registries = require('../../../modules/registries')
const { isAllowedEndpoint } = require('../../../utils/pushDispatch') const { isAllowedEndpoint } = require('../../../utils/pushDispatch')
const log = require('../../../utils/logger')('notifications') const log = require('../../../utils/logger')('notifications')
@@ -49,9 +49,11 @@ async function removeDevice(req, res) {
} }
} }
// GET /auth/me/notifications/streams — the subscribable catalog (static). // GET /auth/me/notifications/streams — the subscribable catalog: core's streams
// plus every installed module's, in registration order. Fixed for the lifetime of
// a process (registration is boot-time), not a static constant.
function getStreams(req, res) { function getStreams(req, res) {
return res.json({ streams: STREAMS }) return res.json({ streams: registries.allStreams() })
} }
// GET /auth/me/notifications/subscriptions — the caller's opted-in stream ids. // GET /auth/me/notifications/subscriptions — the caller's opted-in stream ids.

View File

@@ -21,7 +21,6 @@ const express = require('express')
const { requireAuth } = require('../../../auth/session.middleware') const { requireAuth } = require('../../../auth/session.middleware')
const noindex = require('../../../middleware/noindex') const noindex = require('../../../middleware/noindex')
const modules = require('../../../modules/loader')
const accountRouter = require('./account.router') const accountRouter = require('./account.router')
const shardRouter = require('./shard.router') const shardRouter = require('./shard.router')
@@ -41,8 +40,4 @@ playerRouter.use('/account', accountRouter)
playerRouter.use('/shard', shardRouter) playerRouter.use('/shard', shardRouter)
playerRouter.use('/appeals', appealsRouter) playerRouter.use('/appeals', appealsRouter)
// Installed modules' player routers, already behind this group's
// noindex/requireAuth gate.
modules.mountInto('player', playerRouter)
module.exports = playerRouter module.exports = playerRouter

View File

@@ -21,10 +21,10 @@
// not projecting is a bug, and the cost of honouring it is one call per handler // not projecting is a bug, and the cost of honouring it is one call per handler
// rather than a retrofit the first time a field needs gating. // rather than a retrofit the first time a field needs gating.
const atlas = require('../model/shardAtlas/shardAtlas.model') const atlas = require('../../../model/shardAtlas/shardAtlas.model')
const visibility = require('../utils/visibility') const visibility = require('../../../utils/shardVisibility')
const log = require('../core').logger('public-atlas') const log = require('../../../utils/logger')('public-atlas')
const FEATURE = 'atlas' const FEATURE = 'atlas'

View File

@@ -16,17 +16,13 @@
// The default audience is `anonymous`, so these gates are inert until an admin // The default audience is `anonymous`, so these gates are inert until an admin
// changes something. // changes something.
// express and express-validator come from core, never from a require here: this const express = require('express')
// file lives outside server/, so Node's resolver would not find them, and a const { param, query } = require('express-validator')
// second express in the process would be a second Router prototype
// (docs/website/MODULE_API.md §2.3).
const core = require('../core')
const atlas = require('./atlas.controller')
const { requireFeature } = require('../utils/visibility')
const { express, validator, middleware } = core const atlas = require('./atlas.controller')
const { param, query } = validator const siteMode = require('../../../middleware/siteMode')
const { siteMode, validate } = middleware const validate = require('../../../middleware/validate')
const { requireFeature } = require('../../../utils/shardVisibility')
const atlasRouter = express.Router() const atlasRouter = express.Router()

View File

@@ -17,12 +17,12 @@
const express = require('express') const express = require('express')
const modules = require('../../../modules/loader')
const postsRouter = require('./posts.router') const postsRouter = require('./posts.router')
const wikiRouter = require('./wiki.router') const wikiRouter = require('./wiki.router')
const pagesRouter = require('./pages.router') const pagesRouter = require('./pages.router')
const shardRouter = require('./shard.router') const shardRouter = require('./shard.router')
const atlasRouter = require('./atlas.router')
const modulesRouter = require('./modules.router')
const siteRouter = require('./site.router') const siteRouter = require('./site.router')
const publicRouter = express.Router() const publicRouter = express.Router()
@@ -34,16 +34,17 @@ publicRouter.use('/wiki', wikiRouter)
publicRouter.use('/pages', pagesRouter) publicRouter.use('/pages', pagesRouter)
// Live shard data, never site-mode gated. // Live shard data, never site-mode gated.
publicRouter.use('/shard', shardRouter) publicRouter.use('/shard', shardRouter)
// The spawn atlas: static shard CONTENT, parsed from the shard's ServUO tree
// Installed modules' public routers, each at the prefix it declared in its // rather than fetched from the sidecar. Deliberately not under /shard — nothing
// module.json. Mounted AFTER core's own prefixes so a module can never shadow // here depends on the bridge — and site-mode gated per route like the content
// one even if the loader's collision check were somehow bypassed, and BEFORE the // routers above, which is the other half of that distinction.
// root-mounted siteRouter below for the same reason that one is mounted last. publicRouter.use('/atlas', atlasRouter)
// // What this backend serves beyond core. A real prefix layer rather than a fifth
// The spawn atlas used to sit here as `/atlas`; it is now module-uo's, which is // singleton in site.router.js, because the loader's prefix-collision probe reads
// what the Phase 1 spike is proving (docs/website/MODULE_API.md). The URL is // the live tier stack and skips root-mounted layers — this mount is what makes
// unchanged — routes.manifest.json is the proof. // /modules unclaimable by a module. Never site-mode gated: a client must be able
modules.mountInto('public', publicRouter) // to feature-detect while the site is in maintenance.
publicRouter.use('/modules', modulesRouter)
// The four singletons that own no path segment of their own: /settings, /status, // The four singletons that own no path segment of their own: /settings, /status,
// /version and /contact. Mounted at the group root, last — safe only because // /version and /contact. Mounted at the group root, last — safe only because

View File

@@ -0,0 +1,60 @@
// Public · Modules — what this backend is currently serving beyond core.
//
// Phase 2, PR 6 of docs/website/MODULE_SYSTEM.md §2.7. The published shape is
// settled in MODULE_API.md §2.1 (`capabilities` are opaque strings, published
// here, for clients to feature-detect against).
//
// Two decisions are visible in the ten lines below and are the whole of this
// file's design:
//
// • **`started` only.** The public surface answers "what is serving", and
// nothing else. A module that failed to load, or that an operator disabled,
// is simply ABSENT — the same treatment §4.4 already gives its routes and
// its nav, so an anonymous visitor sees a site without that capability
// rather than a site advertising a capability that 503s. `state`, the
// failure stage and the failure reason are core's business and belong to the
// admin Modules screen; none of the three is published here.
// • **No database, and no siteMode gate.** The answer comes from the loader's
// in-memory records, so this endpoint keeps working with the database down —
// the same class as /public/version and /public/status, both of which must
// answer during maintenance so a client can bootstrap and render the
// maintenance page. A client that could not feature-detect while the site
// was in maintenance would render its maintenance page as though no module
// existed.
//
// This endpoint is deliberately NOT how a module's client chunk gets loaded.
// `utils/htmlShell.js` injects a `<script type="module">` per started module
// (MODULE_API.md §3.1.3), so the browser is handed the tag rather than a URL to
// go and fetch; there is no `client` field here for the same reason there is no
// second copy of any other fact. See MODULE_SYSTEM.md §2.6, amended to match.
const loader = require('../../../modules/loader')
const log = require('../../../utils/logger')('public:modules')
/** id, name, version and capabilities — everything else the loader knows is internal. */
const publish = (m) => ({
id: m.id,
name: m.name,
version: m.version,
capabilities: m.capabilities,
})
function getModules(req, res) {
try {
// Scan order (alphabetical by id) comes from the loader and is preserved:
// there is no dependency resolution, so any other order would imply a
// precedence nothing computes (MODULE_API.md §4.2).
return res.json({ modules: loader.list().filter((m) => m.state === 'started').map(publish) })
} catch (err) {
// The only reachable throw is §7.6's guard — the module list read before
// modules.load() ran. That is a mis-ordered boot, not a bad request, so it
// is logged rather than answered with an empty list: `{ modules: [] }` is a
// true answer for a core with no modules installed and a caller cannot tell
// the two apart.
log.error('module list unavailable', { message: err.message })
return res.status(500).json({ message: 'Internal Server Error' })
}
}
module.exports = { getModules }

View File

@@ -0,0 +1,30 @@
// Public · Modules — the installed-module list a client feature-detects against.
//
// Mounted at /api/v1/public/modules by public/index.js. One route, and it owns a
// prefix rather than sitting beside /settings and /version in site.router.js —
// which is the point of the file existing at all. The loader asks the LIVE
// public tier router whether a prefix is already core's (`ownedByCore`,
// modules/loader.js), and it skips root-mounted layers because a `use('/', …)`
// matches every path. A route declared inside the root-mounted site router is
// therefore invisible to that probe; a real `use('/modules', …)` layer is not.
// So mounting it here is what makes "no module may ever claim /modules" an
// enforced rule instead of a convention.
//
// No siteMode gate and no database — see modules.controller.js for why.
const express = require('express')
const ctrl = require('./modules.controller')
const modulesRouter = express.Router()
modulesRouter.get(
'/',
// #swagger.tags = ['Public']
// #swagger.summary = 'Installed modules (id, version, capabilities)'
// #swagger.description = 'The modules this backend is currently SERVING, in scan order. A module that is disabled or failed to load is absent rather than listed with a state — its routes and nav are absent too, so the client renders a site without that capability. `capabilities` are opaque strings declared by the module for clients (the SPA, the Android app) to feature-detect against; treat an unknown one as absent. Database-free and never gated by site mode, so a client can feature-detect during maintenance.'
/* #swagger.responses[200] = { description: 'The started modules', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicModules" } } } } */
ctrl.getModules,
)
module.exports = modulesRouter

View File

@@ -14,9 +14,10 @@ const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed')
const settings = require('./model/settings/settings.model') const settings = require('./model/settings/settings.model')
const revokedSessions = require('./model/revokedSessions/revokedSessions.model') const revokedSessions = require('./model/revokedSessions/revokedSessions.model')
const mobileAuthBridge = require('./model/mobileAuthBridge/mobileAuthBridge.model') const mobileAuthBridge = require('./model/mobileAuthBridge/mobileAuthBridge.model')
const modules = require('./modules/loader') const shardAtlas = require('./model/shardAtlas/shardAtlas.model')
const shardClilocs = require('./model/shardClilocs/shardClilocs.model') const shardClilocs = require('./model/shardClilocs/shardClilocs.model')
const shardMarket = require('./model/shardMarket/shardMarket.model') const shardMarket = require('./model/shardMarket/shardMarket.model')
const moduleLifecycle = require('./modules/lifecycle')
const createLogger = require('./utils/logger') const createLogger = require('./utils/logger')
const { evaluateBotInternalKey } = require('./utils/botInternalKey') const { evaluateBotInternalKey } = require('./utils/botInternalKey')
const brand = require('./config/brand') const brand = require('./config/brand')
@@ -80,15 +81,16 @@ async function start() {
log.warn('mobile-auth-bridge prune failed', { error: err.message }) log.warn('mobile-auth-bridge prune failed', { error: err.message })
} }
// Installed modules' onBoot hooks. After ensureSchema() and seedDefaults(), so // Re-derive the spawn atlas from the shard's own ServUO tree. The shard's maps
// a module's own tables exist; before the listener binds, so a module that must // change over its lifetime — facets get added, replaced or renamed — so the
// warm a cache before serving gets that for free. Each hook is individually // atlas is rebuilt on every boot rather than shipped as a snapshot that would
// try/caught inside the loader — a module that throws here loses its `started` // silently go stale. Hash-gated, so an unchanged tree costs one read pass and
// state and its routes answer 503, and the site still comes up. // no database write.
// //
// The spawn atlas's boot refresh used to be an explicit call here; it is now // Best-effort by contract: no configured path, an unreadable mount or a
// module-uo's onBoot (docs/website/MODULE_API.md §2.5). // malformed file must never stop the site coming up. A refresh that would
await modules.boot() // REMOVE a facet is staged for admin approval instead of being applied.
await shardAtlas.refreshOnBoot()
// Refresh the cliloc table (UO's id → display-string map) from the file the // Refresh the cliloc table (UO's id → display-string map) from the file the
// operator converted out of their own client. Same contract as the atlas: // operator converted out of their own client. Same contract as the atlas:
@@ -108,6 +110,15 @@ async function start() {
const mode = await settings.get('site_mode') const mode = await settings.get('site_mode')
log.info(`site mode: ${String(mode || 'live').toUpperCase()}`) log.info(`site mode: ${String(mode || 'live').toUpperCase()}`)
// Reconcile installed_modules with what the loader found on the volume at
// require time, then run each module's onBoot (MODULE_API.md §2.5). Placed
// after core's own boot work and BEFORE the listener binds, for both reasons
// the contract gives: a module's warm-up may depend on core being up, and a
// module that must not serve traffic until it has warmed a cache gets that
// guarantee only if nothing is listening yet. Never throws — a module that
// fails here keeps its URLs and answers 503.
await moduleLifecycle.boot()
const server = http.createServer(app) const server = http.createServer(app)
server.listen(PORT, HOST, () => { server.listen(PORT, HOST, () => {
log.info(`listening on http://${HOST}:${PORT} (API at /api/v1, health at /api/health)`) log.info(`listening on http://${HOST}:${PORT} (API at /api/v1, health at /api/health)`)
@@ -173,11 +184,16 @@ function setupShutdown(server, internalServer) {
if (closing) return if (closing) return
closing = true closing = true
log.warn(`${signal} received — shutting down gracefully`) log.warn(`${signal} received — shutting down gracefully`)
// Modules first, while everything they were handed still works: the database
// pool, the push dispatcher and the SSE fan-out are all still open here, and
// a module's onShutdown is the only chance it gets to flush through them
// (MODULE_API.md §2.5). Each hook is budgeted, so one that will not let go
// costs five seconds rather than the whole shutdown.
await moduleLifecycle.shutdown()
botScore.stopSweeper() // stop the bot-store cleanup interval botScore.stopSweeper() // stop the bot-store cleanup interval
announceWorker.stop() // stop the news-announcement dispatcher poller announceWorker.stop() // stop the news-announcement dispatcher poller
uoLinkSocket.stop() // close the uo-link WS ingest client uoLinkSocket.stop() // close the uo-link WS ingest client
shardBroadcast.closeAll() // end any open shard live-feed SSE streams shardBroadcast.closeAll() // end any open shard live-feed SSE streams
await modules.shutdown() // installed modules' onShutdown, bounded, never throwing
server.close(() => log.info('http server closed')) server.close(() => log.info('http server closed'))
if (internalServer) internalServer.close(() => log.info('internal http server closed')) if (internalServer) internalServer.close(() => log.info('internal http server closed'))
try { try {

View File

@@ -2,59 +2,35 @@
// //
// A lightweight, in-process table poller (no Redis/BullMQ in the stack). Every // A lightweight, in-process table poller (no Redis/BullMQ in the stack). Every
// ANNOUNCE_POLL_MS it sweeps announce_jobs for legs that are due — freshly // ANNOUNCE_POLL_MS it sweeps announce_jobs for legs that are due — freshly
// enqueued or past their backoff — and dispatches each one: // enqueued or past their backoff — and dispatches each one through the leg that
// • town crier → uoLinkClient.postTownCrier (sidecar → in-game) // registered itself for that id (modules/registries.js). Core registers
// • discord → botInternalClient.announce (bot → #news channel) // `discord`; module-uo registers `towncrier`; another game's module registers its
// Both clients never throw (they return { ok, status, error }); the model turns // own, and nothing in this file changes.
// each result into done / retry / terminal and owns the backoff + rollup. One //
// leg failing never touches the other. Same setInterval + unref + stop() shape // A leg's client never throws (they return { ok, status, error }) and its
// as middleware/botScore's sweeper, wired into server.js start/shutdown. // classify() turns that into done / retry / terminal, which the model converts to
// backoff + rollup. One leg failing never touches another. Same setInterval +
// unref + stop() shape as middleware/botScore's sweeper, wired into server.js
// start/shutdown.
const announceJobs = require('../model/announceJobs/announceJobs.model') const announceJobs = require('../model/announceJobs/announceJobs.model')
const announceJobsDb = require('../model/announceJobs/announceJobs.db') const announceJobsDb = require('../model/announceJobs/announceJobs.db')
const logic = require('../model/announceJobs/announceJobs.logic')
const posts = require('../model/posts/posts.model') const posts = require('../model/posts/posts.model')
const uoLinkClient = require('./uoLinkClient') const registries = require('../modules/registries')
const botInternalClient = require('./botInternalClient')
const log = require('./logger')('announce-worker') const log = require('./logger')('announce-worker')
const POLL_MS = Number(process.env.ANNOUNCE_POLL_MS) || 15_000 const POLL_MS = Number(process.env.ANNOUNCE_POLL_MS) || 15_000
const TOWNCRIER_DURATION_SEC = Number(process.env.TOWNCRIER_DURATION_SEC) || 3600
function baseUrl() {
return (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '')
}
// ── Leg dispatchers ─────────────────────────────────────────────────────────
// Return the raw client result ({ ok, status, data, error }); classification is
// the model/logic's job.
async function dispatchTownCrier(post) {
const lines = logic.buildTownCrierText(post, { baseUrl: baseUrl() })
// Stable id: re-posting `post-<id>` REPLACES the prior town-crier entry rather
// than stacking a duplicate, so a retry after a partial failure is safe.
return uoLinkClient.postTownCrier({
id: `post-${post.id}`,
lines,
durationSec: TOWNCRIER_DURATION_SEC,
})
}
async function dispatchDiscord(post) {
const base = baseUrl()
// Stored image paths are relative ("/uploads/x.png"); Discord embeds need an
// absolute URL.
const imageUrl = post.image_url ? new URL(post.image_url, base).toString() : null
return botInternalClient.announce({
title: post.title,
excerpt: post.excerpt,
url: `${base}/site/news`,
imageUrl,
})
}
// Process a single due leg of a job: fetch the post, dispatch, classify, record. // Process a single due leg of a job: fetch the post, dispatch, classify, record.
async function processLeg(job, leg) { async function processLeg(job, leg) {
const registered = registries.announceLeg(leg)
if (!registered) {
// A row for a leg nobody registers any more (its module was removed). Leave
// it alone: failing it would make the job roll up terminal on the strength of
// a leg that no longer exists, and reinstalling the module should resume it.
return
}
const post = await posts.getById(job.post_id) const post = await posts.getById(job.post_id)
if (!post) { if (!post) {
// Post was deleted between enqueue and dispatch (the CASCADE usually reaps // Post was deleted between enqueue and dispatch (the CASCADE usually reaps
@@ -63,16 +39,9 @@ async function processLeg(job, leg) {
return return
} }
let result
let classification let classification
try { try {
if (leg === 'towncrier') { classification = registered.classify(await registered.dispatch(post))
result = await dispatchTownCrier(post)
classification = logic.classifyTownCrier(result)
} else {
result = await dispatchDiscord(post)
classification = logic.classifyDiscord(result)
}
} catch (err) { } catch (err) {
// Clients shouldn't throw, but if one does, treat it as a transient failure // Clients shouldn't throw, but if one does, treat it as a transient failure
// rather than crashing the tick. // rather than crashing the tick.
@@ -83,11 +52,10 @@ async function processLeg(job, leg) {
await announceJobs.recordOutcome(job, leg, classification) await announceJobs.recordOutcome(job, leg, classification)
} }
// One sweep: find due jobs and process each due leg. A job may have both legs due // One sweep: find due jobs and process each due leg. A job may have several legs
// (a fresh enqueue) — process the ones that are actually pending. `job` is a // due at once (a fresh enqueue). `job` is a snapshot from the SELECT;
// snapshot from the SELECT; recordOutcome re-reads for the rollup, so processing // recordOutcome re-reads for the rollup, so processing the legs sequentially off
// the two legs sequentially off the same snapshot is fine (each leg only writes // the same snapshot is fine (each leg only writes its own row).
// its own columns).
async function tick(now = new Date()) { async function tick(now = new Date()) {
let jobs let jobs
try { try {
@@ -99,15 +67,15 @@ async function tick(now = new Date()) {
if (!jobs || jobs.length === 0) return if (!jobs || jobs.length === 0) return
for (const job of jobs) { for (const job of jobs) {
if (isLegDue(job, 'towncrier', now)) await processLeg(job, 'towncrier') for (const row of job.legs || []) {
if (isLegDue(job, 'discord', now)) await processLeg(job, 'discord') if (isLegDue(row, now)) await processLeg(job, row.leg)
}
} }
} }
function isLegDue(job, leg, now) { function isLegDue(row, now) {
if (job[`${leg}_status`] !== 'pending') return false if (!row || row.status !== 'pending') return false
const next = job[`${leg}_next_attempt_at`] return row.next_attempt_at == null || new Date(row.next_attempt_at) <= now
return next == null || new Date(next) <= now
} }
let timer = null let timer = null
@@ -118,7 +86,7 @@ function start() {
tick().catch((err) => log.error('announce tick failed', { message: err.message })) tick().catch((err) => log.error('announce tick failed', { message: err.message }))
}, POLL_MS) }, POLL_MS)
if (timer.unref) timer.unref() // don't keep the event loop alive (tests, shutdown) if (timer.unref) timer.unref() // don't keep the event loop alive (tests, shutdown)
log.info('announcement dispatcher started', { pollMs: POLL_MS }) log.info('announcement dispatcher started', { pollMs: POLL_MS, legs: registries.announceLegIds() })
return timer return timer
} }
@@ -129,4 +97,4 @@ function stop() {
} }
} }
module.exports = { start, stop, tick, processLeg, dispatchTownCrier, dispatchDiscord } module.exports = { start, stop, tick, processLeg, isLegDue }

View File

@@ -4,6 +4,7 @@ const mariadb = require('mariadb')
require('dotenv').config() require('dotenv').config()
const log = require('./logger')('db') const log = require('./logger')('db')
const { splitStatements } = require('./sqlStatements')
const pool = mariadb.createPool({ const pool = mariadb.createPool({
host: process.env.DB_HOST || '127.0.0.1', host: process.env.DB_HOST || '127.0.0.1',
@@ -41,67 +42,37 @@ async function query(sql, params) {
const SCHEMA_PATH = path.join(__dirname, '..', '..', 'db', 'schema.sql') const SCHEMA_PATH = path.join(__dirname, '..', '..', 'db', 'schema.sql')
/**
* Split a schema file into executable statements.
*
* Strips `--` comments (full-line AND trailing) before splitting — so a leading
* comment block doesn't get glued onto the statement that follows it, and a `;`
* inside a trailing comment can't chop a statement in half. Safe because the
* schema never puts `--` inside a string literal, which is a rule module
* fragments inherit (docs/website/MODULE_API.md §2.6).
*/
function statementsOf(sql) {
return sql
.split('\n')
.map((line) => {
const i = line.indexOf('--')
return i === -1 ? line : line.slice(0, i)
})
.join('\n')
.split(';')
.map((s) => s.trim())
.filter((s) => s.length > 0)
}
/** /**
* Create tables if they do not exist. Idempotent. Retries while the DB is still * Create tables if they do not exist. Idempotent. Retries while the DB is still
* coming up (important under docker-compose even with a healthcheck). * coming up (important under docker-compose even with a healthcheck).
* *
* Installed modules' schema fragments are replayed immediately after core's, by * Once core's schema is in place, every installed module's schema fragment is
* this same function — there is no migration runner here to model a module one * replayed after it (MODULE_API.md §2.6). That step is deliberately OUTSIDE the
* on, and inventing one for modules alone would leave core and modules on two * retry loop: a fragment that throws is that module's failure, not a signal the
* different schema models (MODULE_SYSTEM.md §1.6). * database is still coming up, and retrying core's whole schema nine more times
* because one module shipped bad SQL would turn a 503'd module into a two-minute
* boot. It is also why this file knows nothing about modules beyond the one call
* below — the discovery, splitting and per-module failure handling all live in
* modules/schema.js, required lazily so that requiring the pool never drags the
* loader in with it.
*/ */
async function ensureSchema({ retries = 10, delayMs = 2000 } = {}) { async function ensureSchema({ retries = 10, delayMs = 2000 } = {}) {
await ensureCoreSchema({ retries, delayMs })
// eslint-disable-next-line global-require
await require('../modules/schema').replayFragments()
}
/** Core's own schema.sql, with the wait-for-the-database retry. */
async function ensureCoreSchema({ retries, delayMs }) {
for (let attempt = 1; attempt <= retries; attempt++) { for (let attempt = 1; attempt <= retries; attempt++) {
try { try {
const conn = await pool.getConnection() const conn = await pool.getConnection()
try { try {
for (const statement of statementsOf(fs.readFileSync(SCHEMA_PATH, 'utf8'))) { const sql = fs.readFileSync(SCHEMA_PATH, 'utf8')
for (const statement of splitStatements(sql)) {
await conn.query(statement) await conn.query(statement)
} }
log.info('schema ensured') log.info('schema ensured')
// Module fragments, after core's. Required lazily: the loader requires
// this file for ctx.db, and a top-level require would be a cycle.
// eslint-disable-next-line global-require
const modules = require('../modules/loader')
for (const fragment of modules.schemaFragments()) {
// Per fragment, not per statement: a module whose schema is broken
// must lose its own tables and nothing else, and must not abort the
// retry loop and take the site's boot with it.
try {
for (const statement of statementsOf(fragment.sql)) {
await conn.query(statement)
}
log.info(`schema ensured for module "${fragment.id}"`)
} catch (err) {
modules.markFailed(fragment.id, `schema fragment: ${err.message}`)
log.error(`module "${fragment.id}" schema fragment failed — its routes will answer 503`, {
reason: err.message,
})
}
}
return return
} finally { } finally {
conn.release() conn.release()
@@ -116,7 +87,15 @@ async function ensureSchema({ retries = 10, delayMs = 2000 } = {}) {
} }
} }
// Idempotent: `pool.end()` throws "pool is already closed" on a second call, and
// closing twice is normal rather than exceptional — a SIGINT followed by a
// SIGTERM reaches the shutdown handler twice, and the test harness closes the
// pool for every file on top of the suites that close it themselves. A teardown
// that fails because it had already succeeded is noise.
let closed = false
async function close() { async function close() {
if (closed) return
closed = true
await pool.end() await pool.end()
} }

View File

@@ -0,0 +1,45 @@
// ── The Discord announce leg ───────────────────────────────────────────────
//
// CORE content — the Discord bot has no game logic (MODULE_SYSTEM.md §1.10), so
// this leg stays in core when module-uo leaves with the town crier. It is written
// in the same shape as a module's leg and registered through the same function
// (modules/registries.js registerAnnounceLeg), because a registry only core's
// hardcoded base bypasses is not exercised until a module arrives.
const botInternalClient = require('./botInternalClient')
const { articleUrl, baseUrl, legError } = require('../model/announceJobs/announceJobs.logic')
// Deliver. Returns the raw client result ({ ok, status, data, error }) — the
// client never throws, and classification is `classify`'s job.
async function dispatch(post) {
const base = baseUrl()
// Stored image paths are relative ("/uploads/x.png"); Discord embeds need an
// absolute URL.
const imageUrl = post.image_url ? new URL(post.image_url, base).toString() : null
return botInternalClient.announce({
title: post.title,
excerpt: post.excerpt,
url: articleUrl(base),
imageUrl,
})
}
// 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.
function classify(result) {
if (result && result.ok) return { outcome: 'done' }
return { outcome: 'retry', error: legError(result) }
}
const leg = {
leg: 'discord',
label: 'Discord #news',
dispatch,
classify,
}
module.exports = { leg, dispatch, classify }

View File

@@ -83,7 +83,7 @@ function absolutize(url) {
* @param {string} html the built index.html * @param {string} html the built index.html
* @param {{logo?: string, favicon?: string, theme?: object|null, moduleEntries?: string[]}} [overrides] * @param {{logo?: string, favicon?: string, theme?: object|null, moduleEntries?: string[]}} [overrides]
* effective brand assets and theme; anything absent falls back to BRAND_* env. * effective brand assets and theme; anything absent falls back to BRAND_* env.
* `moduleEntries` are same-origin URLs of installed modules' prebuilt chunks. * `moduleEntries` are the same-origin URLs of installed modules' client chunks.
* @returns {string} * @returns {string}
*/ */
function render(html, overrides = {}) { function render(html, overrides = {}) {
@@ -103,14 +103,46 @@ function render(html, overrides = {}) {
`<meta name="twitter:description" content="${desc}" />`, `<meta name="twitter:description" content="${desc}" />`,
favicon ? `<link rel="icon" href="${htmlEscape(favicon)}" />` : '', favicon ? `<link rel="icon" href="${htmlEscape(favicon)}" />` : '',
themeStyleTag(overrides.theme), themeStyleTag(overrides.theme),
...moduleScriptTags(overrides.moduleEntries),
] ]
.filter(Boolean) .filter(Boolean)
.join('\n ') .join('\n ')
return html const scripts = moduleScriptTags(overrides.moduleEntries)
const withHead = html
.replace(/<title>[\s\S]*?<\/title>/i, `<title>${title}</title>`) .replace(/<title>[\s\S]*?<\/title>/i, `<title>${title}</title>`)
.replace(/(<meta\s+name="description"\s+content=")[\s\S]*?("\s*\/?>)/i, `$1${desc}$2`) .replace(/(<meta\s+name="description"\s+content=")[\s\S]*?("\s*\/?>)/i, `$1${desc}$2`)
.replace(/<\/head>/i, ` ${tags}\n </head>`) .replace(/<\/head>/i, ` ${tags}\n </head>`)
if (scripts.length === 0) return withHead
return withHead.replace(/<\/body>/i, ` ${scripts.join('\n ')}\n </body>`)
}
// Installed modules' prebuilt client chunks (docs/website/MODULE_API.md §3.1).
//
// `type="module"` with a `src`, never inline: `script-src 'self'` admits a
// same-origin src with no nonce, and an inline tag would be blocked outright —
// which is also why the shared dependencies ride on window.__rg rather than an
// import map, since an import map has to be inline.
//
// **Injected before `</body>`, not into `</head>`, and the position is the
// contract.** Module scripts are deferred, so they execute in document order
// after core's own bundle — which is where `window.__rg` is published, and what
// every one of a module's imports resolves against. Vite happens to hoist core's
// entry script into `<head>` today, which would make a `</head>` injection work
// too; that is a bundler's emit choice, and if it ever changed, every module in
// the wild would break on its first import with nothing in this repo having been
// edited. Last in the body is after core's script wherever core's script is.
//
// The path is built by the loader from the module id and the entry's basename,
// both already validated, so nothing operator-supplied reaches the attribute.
// It is re-checked here anyway: what may appear in an HTML attribute should be a
// property of the code that writes the HTML, not of a validator two files away
// staying strict.
const MODULE_ENTRY_PATH = /^\/modules\/[a-z][a-z0-9-]{1,31}\/[A-Za-z0-9][A-Za-z0-9._-]*\.js$/
function moduleScriptTags(entries) {
if (!Array.isArray(entries)) return []
return entries
.filter((src) => typeof src === 'string' && MODULE_ENTRY_PATH.test(src))
.map((src) => `<script type="module" src="${htmlEscape(src)}"></script>`)
} }
// The admin theme as a :root block, or '' when this instance has never been // The admin theme as a :root block, or '' when this instance has never been
@@ -125,27 +157,6 @@ function themeStyleTag(theme) {
return decls ? `<style id="${THEME_STYLE_ID}">:root{${decls}}</style>` : '' return decls ? `<style id="${THEME_STYLE_ID}">:root{${decls}}</style>` : ''
} }
// Installed modules' prebuilt client chunks (docs/website/MODULE_API.md §3.1).
//
// `type="module"` with a `src`, never inline: `script-src 'self'` admits a
// same-origin src with no nonce, and an inline tag would be blocked outright —
// which is also why the shared dependencies ride on window.__rg rather than an
// import map, since an import map has to be inline.
//
// `defer` is implicit for a module script, so these evaluate after the SPA's own
// bundle has published window.__rg and before it renders. The path is built by
// the loader from the module id, so nothing user-supplied reaches the attribute;
// it is escaped anyway, because a rule about what CAN appear here should not
// depend on a validator three modules away staying strict.
const MODULE_ENTRY_PATH = /^\/modules\/[a-z][a-z0-9-]{1,31}\/[A-Za-z0-9._-]+\.js$/
function moduleScriptTags(entries) {
if (!Array.isArray(entries)) return []
return entries
.filter((src) => typeof src === 'string' && MODULE_ENTRY_PATH.test(src))
.map((src) => `<script type="module" src="${htmlEscape(src)}"></script>`)
}
/** /**
* Provide the built index.html. Called once at boot by app.js; a separate step * Provide the built index.html. Called once at boot by app.js; a separate step
* from get() so the file read stays synchronous and startup still fails loudly * from get() so the file read stays synchronous and startup still fails loudly
@@ -193,17 +204,25 @@ async function get() {
// mean a failing query per page view. // mean a failing query per page view.
overrides = {} overrides = {}
} }
// The module list is filesystem-derived and synchronous, so unlike the brand // The module list is in-memory and filesystem-derived, so unlike the brand
// read above it cannot fail on a DB fault and needs no fallback. Only STARTED // read above it cannot fail on a DB fault and needs no fallback of its own.
// modules get a script tag: a module whose onBoot failed answers 503 on its // Required lazily for the same reason the settings model is: app.js requires
// API, and loading its client half would render pages against a dead backend. // this file, and the loader would otherwise be pulled into that chain.
// eslint-disable-next-line global-require let moduleEntries = []
const modules = require('../modules/loader') try {
const moduleEntries = modules // eslint-disable-next-line global-require
.list() moduleEntries = require('../modules/loader').clientEntryUrls()
.filter((m) => m.state === 'started' && m.entryUrl) } catch {
.map((m) => m.entryUrl) // The only reachable throw is §7.6's guard — the shell rendered before
// modules.load() ran, which app.js's ordering makes impossible and a test
// that renders in isolation makes possible. A page with no module scripts
// is the right answer either way; it is what a bare core serves.
moduleEntries = []
}
// Note for whoever builds the admin Modules screen: a state change after boot
// (an operator disabling a module) has to call invalidate(), exactly as a
// brand-asset write does. The TTL converges on its own within five minutes;
// the explicit call is what makes the toggle feel like it did something.
const html = render(template, { ...overrides, moduleEntries }) const html = render(template, { ...overrides, moduleEntries })
// An invalidation that landed while this read was in flight means the value // An invalidation that landed while this read was in flight means the value
// we just read may already be stale. Serve it, but do not cache it. // we just read may already be stale. Serve it, but do not cache it.

View File

@@ -1,10 +1,14 @@
// ── Push-notification fan-out (content-free tickles) ─────────────────────── // ── Push-notification fan-out (content-free tickles) ───────────────────────
// //
// The transport-agnostic publisher that turns an event into opt-in push // The transport-agnostic publisher that turns a stream id into opt-in push
// notifications. Two producers call in: // notifications. It knows nothing about where the stream came from: the admin
// • utils/shardIngest.js → fromShardEvent(event) for shard-derived streams // create/publish-post path calls publish('news.post', …), and utils/shardPush.js
// (beside the existing SSE broadcast — same event source, same allowlist). // resolves a shard event to a stream and an owner and calls the same function.
// • the admin create/publish-post path → publish('news.post', …). //
// That split is MODULE_SYSTEM.md §1.8's second entanglement, inverted. This file
// used to own `fromShardEvent()`, which required the shardLinks model and the
// shard event mapper — core infrastructure reaching into game content. Now the
// content side calls in, and a module reaches this through `ctx.push.publish`.
// //
// What actually leaves the server is a CONTENT-FREE tickle — `{ stream, ref }`, // What actually leaves the server is a CONTENT-FREE tickle — `{ stream, ref }`,
// no sensitive data — POSTed to each subscribed device's UnifiedPush/ntfy // no sensitive data — POSTed to each subscribed device's UnifiedPush/ntfy
@@ -18,9 +22,7 @@
// registration AND every publish: HTTPS only, never a private/loopback host, and // registration AND every publish: HTTPS only, never a private/loopback host, and
// (when configured) the origin must be in the shard's ntfy allow-set. // (when configured) the origin must be in the shard's ntfy allow-set.
const shardLinks = require('../model/shardLinks/shardLinks.model')
const pushDevicesModel = require('../model/pushDevices/pushDevices.model') const pushDevicesModel = require('../model/pushDevices/pushDevices.model')
const { mapShardEvent } = require('../config/notificationStreams')
const log = require('./logger')('push-dispatch') const log = require('./logger')('push-dispatch')
const TIMEOUT_MS = 5000 const TIMEOUT_MS = 5000
@@ -106,30 +108,4 @@ async function publish(streamId, { ref, ownerUserId } = {}, deps = {}) {
await Promise.all(rows.map((r) => postTickle(r.endpoint, bodyStr, deps))) await Promise.all(rows.map((r) => postTickle(r.endpoint, bodyStr, deps)))
} }
// Fan a shard event out to push. Resolves personal (owner-keyed) targets to the module.exports = { publish, isAllowedEndpoint }
// owning website user via shardLinks (an unlinked account → nobody to notify).
// Never throws — a dead relay must never affect ingest.
async function fromShardEvent(event, deps = {}) {
const links = deps.shardLinks || shardLinks
const targets = mapShardEvent(event, deps.tracker)
for (const t of targets) {
try {
if (t.ownerAccount) {
let owner = null
try {
owner = await links.getByAccount(t.ownerAccount)
} catch {
owner = null
}
if (!owner || owner.userId == null) continue
await publish(t.streamId, { ref: t.ref, ownerUserId: owner.userId }, deps)
} else {
await publish(t.streamId, { ref: t.ref }, deps)
}
} catch (err) {
log.warn('push dispatch target failed', { streamId: t.streamId, message: err.message })
}
}
}
module.exports = { publish, fromShardEvent, isAllowedEndpoint }

View File

@@ -0,0 +1,77 @@
// ── The in-game town-crier announce leg ────────────────────────────────────
//
// MODULE-UO CONTENT, still living in core. MODULE_SYSTEM.md §1.8's third
// entangled file: utils/announceWorker.js is core's news dispatcher, but one of
// its two delivery legs goes to the shard through uoLinkClient.postTownCrier.
// PR 4 turned the legs into registrations, and this file is what module-uo will
// register in Phase 3 — it moves whole, with `'core'` becoming `'uo'` and the
// leg id staying `towncrier` (grandfathered in registries.js: the id is a stored
// value in announce_job_legs.leg).
const uoLinkClient = require('./uoLinkClient')
const { deriveExcerpt } = require('./sanitizeHtml')
const { articleUrl, baseUrl, legError } = require('../model/announceJobs/announceJobs.logic')
const TOWNCRIER_DURATION_SEC = Number(process.env.TOWNCRIER_DURATION_SEC) || 3600
// 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 an error.
const MAX_LINES = 8
const MAX_LINE_LEN = 200
// 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()}…`
}
// 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: base } = {}) {
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(base), MAX_LINE_LEN)
if (url) lines.push(url)
return lines.filter(Boolean).slice(0, MAX_LINES)
}
async function dispatch(post) {
const lines = buildTownCrierText(post, { baseUrl: baseUrl() })
// Stable id: re-posting `post-<id>` REPLACES the prior town-crier entry rather
// than stacking a duplicate, so a retry after a partial failure is safe.
return uoLinkClient.postTownCrier({
id: `post-${post.id}`,
lines,
durationSec: TOWNCRIER_DURATION_SEC,
})
}
function classify(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) }
}
const leg = {
leg: 'towncrier',
label: 'In-game town crier',
dispatch,
classify,
}
module.exports = { leg, dispatch, classify, buildTownCrierText, MAX_LINES, MAX_LINE_LEN }

View File

@@ -19,7 +19,7 @@ const shardMarketModel = require('../model/shardMarket/shardMarket.model')
const uoLinkConfigModel = require('../model/uoLinkConfig/uoLinkConfig.model') const uoLinkConfigModel = require('../model/uoLinkConfig/uoLinkConfig.model')
const settingsModel = require('../model/settings/settings.model') const settingsModel = require('../model/settings/settings.model')
const broadcaster = require('./shardBroadcast') const broadcaster = require('./shardBroadcast')
const pushDispatch = require('./pushDispatch') const shardPush = require('./shardPush')
const defaultLog = require('./logger')('shard-ingest') const defaultLog = require('./logger')('shard-ingest')
// Notable kinds appended to the shard_events log. High-frequency/session kinds // Notable kinds appended to the shard_events log. High-frequency/session kinds
@@ -262,7 +262,7 @@ function resolveDeps(deps) {
uoLinkConfig: deps.uoLinkConfig || uoLinkConfigModel, uoLinkConfig: deps.uoLinkConfig || uoLinkConfigModel,
settings: deps.settings || settingsModel, settings: deps.settings || settingsModel,
broadcast: deps.broadcast || broadcaster.broadcast, broadcast: deps.broadcast || broadcaster.broadcast,
pushDispatch: deps.pushDispatch || pushDispatch.fromShardEvent, pushDispatch: deps.pushDispatch || shardPush.fromShardEvent,
log: deps.log || defaultLog, log: deps.log || defaultLog,
} }
} }

View File

@@ -0,0 +1,47 @@
// ── Shard event → push fan-out ─────────────────────────────────────────────
//
// MODULE-UO CONTENT, still living in core — the inverted half of
// MODULE_SYSTEM.md §1.8's second entangled file. `utils/pushDispatch.js` is core
// infrastructure, but its `fromShardEvent()` required the shardLinks model and
// the shard event mapper, which is a core file importing content. PR 4 inverted
// it: `publish()` stays core, and this — the thing that knows what a shard event
// is — moved out to call it. Phase 3 moves this file to module-uo whole, where it
// will reach `publish` through `ctx.push.publish` instead of a require.
//
// Owner resolution is the reason this cannot just be a mapper: a personal
// (owner-keyed) target names a GAME account, and turning that into a website user
// needs the shardLinks model. An unlinked account is simply nobody to notify.
const shardLinks = require('../model/shardLinks/shardLinks.model')
const { mapShardEvent } = require('../config/shardStreams')
const { publish } = require('./pushDispatch')
const log = require('./logger')('shard-push')
// Fan a shard event out to push. Resolves personal (owner-keyed) targets to the
// owning website user via shardLinks (an unlinked account → nobody to notify).
// Never throws — a dead relay must never affect ingest.
async function fromShardEvent(event, deps = {}) {
const links = deps.shardLinks || shardLinks
const doPublish = deps.publish || publish
const targets = mapShardEvent(event, deps.tracker)
for (const t of targets) {
try {
if (t.ownerAccount) {
let owner = null
try {
owner = await links.getByAccount(t.ownerAccount)
} catch {
owner = null
}
if (!owner || owner.userId == null) continue
await doPublish(t.streamId, { ref: t.ref, ownerUserId: owner.userId }, deps)
} else {
await doPublish(t.streamId, { ref: t.ref }, deps)
}
} catch (err) {
log.warn('push dispatch target failed', { streamId: t.streamId, message: err.message })
}
}
}
module.exports = { fromShardEvent }

View File

@@ -0,0 +1,37 @@
// ── Splitting a .sql file into statements ──────────────────────────────────
//
// Extracted from utils/db.js so that core's schema.sql and a module's schema
// fragment are split by literally the same code. MODULE_API.md §2.6 promises a
// fragment is replayed "statement by statement, split the same way" — with two
// copies of this that promise would hold only until one of them was edited.
//
// It lives in its own file rather than being exported from utils/db.js because
// modules/loader.js validates fragments at require time and must not pull the
// mariadb pool into app.js's require chain to do it.
/**
* Split a .sql file into individual statements.
*
* Strips `--` comments (full-line AND trailing) before splitting — so a leading
* comment block doesn't get glued onto the statement that follows it, and a `;`
* inside a trailing comment can't chop a statement in half. Safe because neither
* core's schema nor a conforming fragment puts `--` inside a string literal
* (§2.6 states that as a rule a fragment must follow).
*
* @param {string} sql
* @returns {string[]} non-empty, trimmed statements in file order
*/
function splitStatements(sql) {
return sql
.split('\n')
.map((line) => {
const i = line.indexOf('--')
return i === -1 ? line : line.slice(0, i)
})
.join('\n')
.split(';')
.map((s) => s.trim())
.filter((s) => s.length > 0)
}
module.exports = { splitStatements }

115
server/swagger/mergeSpec.js Normal file
View File

@@ -0,0 +1,115 @@
// ── Merging an OpenAPI fragment into a spec ────────────────────────────────
//
// The merge half of docs/website/MODULE_API.md §6.1's settled decision: routes
// that reach the app through something swagger-autogen cannot statically follow
// contribute a FRAGMENT, and core merges it.
//
// Two callers, one function, deliberately:
// • build time — swagger/slotSpecs.js, for core's own extension-slot routers
// (§1.9). Those are core routes that a static parse of app.js cannot see,
// because the slot's router is created by registries.declareSlot() and filled
// later. They belong in the committed `swagger-output.json`.
// • request time (Phase 2 PR 6+) — an installed module's `swagger-fragment.json`,
// merged over the committed spec for `/api/docs.json`.
//
// **Core always wins a key collision** (§6.1a). A fragment cannot redefine a path,
// a tag or a schema core already declares; the collision is reported and the
// fragment's version dropped. Merging is shallow-per-section — `paths`, `tags`
// and `components.schemas` — because those are the only three sections a fragment
// is allowed to carry, and a deeper merge would let a fragment reach into
// `info`, `servers` or the security schemes.
/**
* Merge `fragment` into `spec`, in place, with core winning every collision.
*
* @param {object} spec the base spec — mutated
* @param {object} fragment `{ paths?, tags?, components?: { schemas? } }`
* @param {string} source who the fragment came from, for the collision message
* @returns {string[]} the collisions that were dropped (empty when clean)
*/
function mergeFragment(spec, fragment, source) {
const dropped = []
for (const [path, item] of Object.entries(fragment.paths || {})) {
if (spec.paths[path]) {
// Not a merge of the two path items: a fragment adding a METHOD to a core
// path is the same overreach as replacing it, and the extension-slot
// contract already says core owns the resource (§2.4).
dropped.push(`path ${path}`)
continue
}
spec.paths[path] = item
}
const tagNames = new Set((spec.tags || []).map((t) => t.name))
for (const tag of fragment.tags || []) {
if (tagNames.has(tag.name)) continue // same tag, not a collision worth reporting
spec.tags.push(tag)
tagNames.add(tag.name)
}
const schemas = (fragment.components && fragment.components.schemas) || {}
for (const [name, schema] of Object.entries(schemas)) {
if (spec.components.schemas[name]) {
dropped.push(`schema ${name}`)
continue
}
spec.components.schemas[name] = schema
}
if (dropped.length > 0) {
process.stderr.write(
`swagger: dropped ${dropped.length} colliding key(s) from ${source} — core wins: ${dropped.join(', ')}\n`,
)
}
return dropped
}
/**
* Re-root a fragment's paths under the prefix its router is actually mounted at.
*
* A fragment generated by pointing swagger-autogen at a router file alone has
* paths relative to that router (`/shard/accounts`), because nothing in the file
* says where it hangs. The prefix comes from the LIVE express stack rather than a
* table, so it cannot drift the way a hand-written mount list would.
*
* Express path params (`:id`) become OpenAPI's (`{id}`) — swagger-autogen already
* does that for the paths it generates, so the prefix has to match.
*/
function prefixPaths(fragment, prefix) {
const oas = prefix.replace(/:([A-Za-z0-9_]+)/g, '{$1}').replace(/\/+$/, '')
const outer = [...oas.matchAll(/\{([A-Za-z0-9_]+)\}/g)].map((m) => m[1])
const paths = {}
for (const [p, item] of Object.entries(fragment.paths || {})) {
paths[`${oas}${p}`] = orderParams(item, outer)
}
return { ...fragment, paths }
}
/**
* Put the prefix's own path parameters first, in prefix order.
*
* swagger-autogen orders parameters by where they appear in the path it saw, and
* the fragment's path is only the tail — so `/{id}/shard/link/{account}` comes
* out as (account, id) rather than (id, account). Re-rooting the path has to
* re-root the parameter order with it, or every slot route churns the committed
* spec by a reorder that means nothing.
*/
function orderParams(item, outer) {
for (const operation of Object.values(item)) {
const params = operation && operation.parameters
if (!Array.isArray(params)) continue
const rank = (p) => {
const i = outer.indexOf(p && p.name)
return i === -1 ? outer.length : i
}
// Stable: only the prefix params move, and only ahead of the rest.
operation.parameters = params
.map((p, i) => ({ p, i }))
.sort((a, b) => rank(a.p) - rank(b.p) || a.i - b.i)
.map(({ p }) => p)
}
return item
}
module.exports = { mergeFragment, prefixPaths }

120
server/swagger/slotSpecs.js Normal file
View File

@@ -0,0 +1,120 @@
// ── OpenAPI for core's extension-slot routers ──────────────────────────────
//
// The second half of `npm run swagger`. It exists because of a failure mode that
// announces itself as a success.
//
// `swagger/swagger.js` is STATIC analysis: swagger-autogen parses `src/app.js` as
// text and follows the literal `app.use(...)` mount chain. An extension slot
// (MODULE_SYSTEM.md §1.9) breaks that chain on purpose — the slot's router is
// created by `registries.declareSlot()` and filled later, so there is no literal
// require for the parser to follow. When PR 4 moved the six `/admin/users/:id/shard/*`
// routes behind the `admin.users.detail` slot, regenerating the spec printed
// `Swagger-autogen: Success` and deleted 407 lines. Nothing failed. The spike hit
// the identical thing (MODULE_API.md §7.4) and it is why the fragment merge is
// the settled answer (§6.1).
//
// So: generate a fragment per filled slot by pointing swagger-autogen at that
// router's own file, re-root its paths at the prefix the router is ACTUALLY
// mounted at in the live app, and merge. Two things are deliberately derived
// rather than written down, because a written-down copy is a copy that drifts:
//
// • WHICH slots — from `registries.filledSlots()`, not a list here.
// • WHERE each hangs — by finding the slot's own router object in the live
// express stack and accumulating the mount prefixes above it, using
// `scripts/routeManifest.js`'s `mountPath` so the manifest and the spec can
// never disagree about what a mount decodes to.
//
// This is core's own slot fill only. A MODULE ships a prebuilt
// `swagger-fragment.json` in its bundle and core merges it at request time
// (§6.1a) — core never has a module's sources to analyse.
const fs = require('fs')
const os = require('os')
const path = require('path')
const swaggerAutogen = require('swagger-autogen')({ openapi: '3.0.0' })
const { mergeFragment, prefixPaths } = require('./mergeSpec')
const { mountPath } = require('../scripts/routeManifest')
const SERVER_ROOT = path.join(__dirname, '..')
/**
* Find `target` in an express stack and return the path prefix it is mounted at.
*
* Depth-first, accumulating each enclosing mount. Returns null when the router is
* not on the stack at all — which for a filled slot means core declared it and
* never mounted it, a bug worth failing the build over rather than papering over
* with an unprefixed path.
*/
function findMountPrefix(stack, target, prefix = '') {
for (const layer of stack || []) {
if (!layer.handle || !Array.isArray(layer.handle.stack)) continue
const here = prefix + mountPath(layer)
if (layer.handle === target) return here
const found = findMountPrefix(layer.handle.stack, target, here)
if (found !== null) return found
}
return null
}
/**
* Generate one fragment by running swagger-autogen over a single router file.
*
* Its paths come out relative to that router (`/shard/accounts`) because nothing
* in the file says where it hangs; `prefixPaths` supplies the rest.
*/
async function fragmentFor(specFile) {
const out = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'rg-swagger-')), 'fragment.json')
await swaggerAutogen(out, [path.relative(SERVER_ROOT, specFile).split(path.sep).join('/')], {
info: { title: 'slot fragment', version: '0' },
})
const fragment = JSON.parse(fs.readFileSync(out, 'utf8'))
fs.rmSync(path.dirname(out), { recursive: true, force: true })
return fragment
}
/**
* Merge every filled core slot's routes into the generated spec file, in place.
*
* @param {string} outputFile the swagger-output.json swagger.js just wrote
* @returns {Promise<number>} how many paths were added
*/
async function mergeSlotSpecs(outputFile) {
/* eslint-disable global-require */
const app = require('../src/app') // builds the app: declares and fills the slots
const registries = require('../src/modules/registries')
/* eslint-enable global-require */
const filled = registries.filledSlots().filter((s) => s.specFile)
if (filled.length === 0) return 0
const spec = JSON.parse(fs.readFileSync(outputFile, 'utf8'))
let added = 0
for (const slot of filled) {
const prefix = findMountPrefix(app._router.stack, slot.router)
if (prefix === null) {
throw new Error(
`swagger: extension slot "${slot.slot}" is filled but its router is not mounted on the app — ` +
'declareSlot() returned a router nobody use()d.',
)
}
const fragment = prefixPaths(await fragmentFor(slot.specFile), prefix)
const paths = Object.keys(fragment.paths || {}).length
if (paths === 0) {
throw new Error(
`swagger: extension slot "${slot.slot}" generated an EMPTY fragment from ${slot.specFile}. ` +
'That is the silent-drop failure this step exists to catch, not a slot with no routes.',
)
}
mergeFragment(spec, fragment, `slot ${slot.slot}`)
added += paths
process.stdout.write(`merged ${paths} path(s) from slot ${slot.slot} at ${prefix}\n`)
}
fs.writeFileSync(outputFile, `${JSON.stringify(spec, null, 2)}\n`)
return added
}
module.exports = { mergeSlotSpecs, findMountPrefix }

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