25 Commits

Author SHA1 Message Date
222bcb02fc Merge pull request 'fix(demo): switch on the actions the seeded events use (DM28)' (#231) from fix/demo-seed-enables-actions into main
Some checks failed
sync-project-tree / sync (push) Successful in -2m16s
Build container images / build (push) Successful in 1m39s
Build container images / deploy (push) Successful in 39s
SonarQube / analysis (push) Failing after 6m19s
Demo wipe / wipe (push) Failing after 12m53s
Reviewed-on: #231
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-10-28 09:55:24 +00:00
ebedee146f fix(demo): switch on the actions the seeded events use (DM28)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 54s
PR Checks / server-tests (pull_request) Successful in 6m6s
PR Checks / client-build (pull_request) Successful in 7m33s
The step-5 walk found the demo's three seeded events (Server greeting,
Treasure drop, Payday) showing as ready but failing when run: a fresh
database has no switchboard rows, so every change and irreversible action
starts disabled ("not enabled on this deployment").

seed:demo now switches on, with the events, every action their steps use,
uncapped, as an admin flipping the switch without filling the cap boxes
would. Nothing else is switched on. The deny list (DM11) still keeps a
visitor's own command step harmless, and the wipe drops the rows with the
rest of the database.

Checked on a scratch database with the v0.6.1 module: the seed leaves
rust.command.run, rust.dt.start and rust.coins.pay enabled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-28 04:44:25 -05:00
47a79d841c Merge pull request 'feat(demo): seed the default set's events, NPCs and chat titles' (#230) from feature/demo-default-events into main
Some checks failed
sync-project-tree / sync (push) Successful in 14s
Build container images / build (push) Successful in 1m14s
Build container images / deploy (push) Successful in 59s
SonarQube / analysis (push) Failing after 6m13s
Demo wipe / wipe (push) Successful in 7m7s
Reviewed-on: #230
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-10-28 02:21:04 +00:00
62eaa7693d Merge branch 'main' into feature/demo-default-events
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 40s
PR Checks / client-build (pull_request) Successful in 1m8s
PR Checks / server-tests (pull_request) Successful in 12m26s
2026-10-28 02:05:42 +00:00
0f05098d8f Merge pull request 'feat(demo): refuse a data: URI in any write body (DM25)' (#229) from feature/demo-data-uri-lock into main
Some checks failed
sync-project-tree / sync (push) Successful in 15s
Build container images / build (push) Successful in 1m34s
Build container images / deploy (push) Successful in 41s
SonarQube / analysis (push) Failing after 6m18s
Demo wipe / wipe (push) Successful in 7m15s
Reviewed-on: #229
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-10-28 01:28:28 +00:00
8ec7f399a7 Merge branch 'main' into feature/demo-data-uri-lock
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 41s
PR Checks / client-build (pull_request) Successful in 55s
PR Checks / server-tests (pull_request) Successful in 6m25s
2026-10-28 01:21:50 +00:00
38aef5964e feat(demo): seed the default set's NPC profiles, factions and chat titles (DM26)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 47s
PR Checks / client-build (pull_request) Successful in 1m8s
PR Checks / server-tests (pull_request) Successful in 12m27s
The restored rig's RunicNPC says it is already managed, so a fresh site
adopts none of its profiles and its first push empties them; chat styles
are never adopted either. They come from the fixture now, through
Module-Rust's onDemoSeed (needs the release carrying DM26; an older module
ignores these keys):

- npcs.factions: bandits and guards, allied;
- npcs.profiles: bandit (roamer, rnpc_raider) and guard (sentry, heavy,
  rnpc_campguard, heals), both on the demo server. Only the fields that
  differ from RunicNPC's defaults are listed;
- chat: [VIP] in gold and [MOD] in green.

The baseline's placements (bandit-1, bandit-2, guard-1) spawn again once
the site pushes these.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-27 20:16:27 -05:00
9408fee3c1 feat(demo): seed two showcase events, Treasure drop and Payday
All checks were successful
PR Checks / client-build (pull_request) Successful in 1m35s
PR Checks / server-tests (pull_request) Successful in 6m41s
PR Checks / bot-tests (pull_request) Successful in 7m14s
The demo default set (org lead, 2026-10-27: curated showcase) puts its
permissions and NPCs on the rig, where the baseline restores them; events
live in the site's database, which every wipe drops, so they belong in the
fixture. Both are manual, published, on the demo server:

- Treasure drop: rust.dt.start, Easy.
- Payday: rust.coins.pay, 500 coins to every linked player, said in chat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-27 13:35:55 -05:00
c18250eb07 Merge pull request 'fix(demo): chown modules/ and logs/ from a root container in the wipe' (#228) from fix/demo-wipe-chown into main
Some checks failed
sync-project-tree / sync (push) Successful in 12s
Build container images / build (push) Successful in 2m22s
Build container images / deploy (push) Successful in 47s
SonarQube / analysis (push) Failing after 8m32s
Demo wipe / wipe (push) Successful in 7m22s
Reviewed-on: #228
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-10-27 18:15:24 +00:00
61feca50a8 feat(demo): refuse a data: URI in any write body (DM25)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 45s
PR Checks / client-build (pull_request) Successful in 1m2s
PR Checks / server-tests (pull_request) Successful in 6m4s
Uploads are locked hard on the demo, but an image pasted into rich text
does not use multipart: it is saved as a data: URI inside the JSON body.
refuseDataUri, mounted beside refuseMultipart, refuses any POST/PUT/PATCH/
DELETE whose body has a data: URI in any string, on any route, core's or
a module's, with the usual 403 DEMO_LOCKED.

Each string is first read the way a browser reads an attribute (HTML
character references decoded, tabs and newlines removed), so &#100;ata: and
a data: split by a newline are refused too. "metadata:" and prose such as
"Data: none, yet" pass. Reads are never checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-27 13:13:51 -05:00
7aafd0dd74 fix(demo): chown modules/ and logs/ from a root container in the wipe
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 40s
PR Checks / server-tests (pull_request) Successful in 6m1s
PR Checks / client-build (pull_request) Successful in 7m27s
The runner user is not root, so the host chown failed and `|| true` hid it.
The app (uid 1000) then could not unpack the declared module into ./modules,
came up healthy with no module, and seed:demo refused the fixture's
rust.command.run event (website run 358, the first scheduled wipe).

A throwaway container runs as root and the runner is in the docker group, so
the chown now happens there, and a failure stops the run at that step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-27 13:04:44 -05:00
3b9998539f Merge pull request 'fix(docker): move the app and bot images to Node 22.23.4 (Node2Shell)' (#226) from fix/node22-node2shell into main
Some checks failed
sync-project-tree / sync (push) Successful in 15s
Build container images / build (push) Successful in 4m56s
Build container images / deploy (push) Successful in 1m3s
SonarQube / analysis (push) Failing after 7m37s
Demo wipe / wipe (push) Failing after 1m52s
Reviewed-on: #226
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-10-27 17:40:01 +00:00
3c1c174337 Merge branch 'main' into fix/node22-node2shell
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 45s
PR Checks / client-build (pull_request) Successful in 1m5s
PR Checks / server-tests (pull_request) Successful in 6m35s
2026-10-27 17:33:14 +00:00
247323ff05 Merge pull request 'feat(demo): deploy/demo and the wipe + baseline workflows (step 5)' (#227) from feature/demo-deploy into main
Some checks failed
Demo wipe / wipe (push) Failing after 25s
sync-project-tree / sync (push) Successful in 33s
Build container images / build (push) Successful in 2m18s
Build container images / deploy (push) Successful in 51s
SonarQube / analysis (push) Has been cancelled
Reviewed-on: #227
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-10-27 17:31:45 +00:00
d48d8c473c feat(demo): deploy/demo and the wipe + baseline workflows (step 5)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 50s
PR Checks / client-build (pull_request) Successful in 57s
PR Checks / server-tests (pull_request) Successful in 12m25s
The public demo's deployment (docs/website/DEMO_MODE.md §7, §10 step 5):

- deploy/demo/docker-compose.yml: the app and its database only, DEMO_MODE
  on, a /status healthcheck, the fixture mounted for seed:demo, MODULES read
  from state/modules.env so the workflow never edits the .env with secrets.
- deploy/demo/.env.example: every variable the host needs, including
  BOT_INTERNAL_KEY (the app refuses to start in production without it) and
  MODULE_SOURCE_HOSTS (a fresh database's install allowlist).
- deploy/demo/seed.json: the four logins, settings, two posts, a wiki page,
  a published "Server greeting" event on a Rust console step, the rig's
  server link and demo-player's Steam link; secrets as "$env:NAME".
- deploy/demo/rig.mjs: plan (latest Rust bundle + newest Module-Rust on the
  same protocol, keeping the previous set when none matches), restore (kill,
  write a changed bundle, delete + unpack demo-baseline.tar.gz, start),
  baseline (power stop saves the world, DM22; compress, rename, start) and
  smoke (the site, then the rig reporting in), on the Pterodactyl client API.
- .gitea/workflows/demo-wipe.yml: every 2 h on the hour UTC, on a push to
  deploy/demo/, and by hand; rig first, then down -v / pull / up / seed.
- .gitea/workflows/demo-baseline.yml: by hand only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-26 12:23:52 -05:00
2a6e307f16 fix(docker): move the app and bot images to Node 22.23.4 (Node2Shell)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 1m1s
PR Checks / client-build (pull_request) Successful in 2m13s
PR Checks / server-tests (pull_request) Successful in 7m19s
CVE-2026-111497 ("Node2Shell") is an out-of-bounds write in the Brotli
decoder Node bundles; it was fixed on 2026-10-21 in 22.23.4, 24.22.1 and
26.12.1. Both images were on node:20-alpine, and Node 20 is past end of
life, so it gets no fix.

The site is not reachable through the main path (Express 4 / body-parser
1.20 refuse a `br` request body with 415, and nothing calls the zlib
Brotli API), but fetch() decodes Brotli responses and the runtime itself
was unpatched. Pin the exact patched version so a floating tag cannot
hold an old build, and run CI on the same version the image ships.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-26 10:00:02 -05:00
c7a24a186c Merge pull request 'fix(demo): refuse publishing the homepage hero (DM21)' (#225) from fix/demo-hero-publish-lock into main
Some checks failed
sync-project-tree / sync (push) Successful in 25s
Build container images / build (push) Successful in -13s
Build container images / deploy (push) Successful in 47s
SonarQube / analysis (push) Failing after 6m25s
Reviewed-on: #225
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-10-26 14:50:55 +00:00
c87563bc32 fix(demo): refuse publishing the homepage hero (DM21)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 47s
PR Checks / client-build (pull_request) Successful in 54s
PR Checks / server-tests (pull_request) Successful in 12m42s
Settings stay open on the demo (DM2), so the hero editor's Publish wrote
hero_layout, the hero every visitor sees. hero_layout joins the protected
settings keys: PUT /admin/settings naming it answers 403 DEMO_LOCKED with
keys: ['hero_layout']. The editor's draft autosave and Preview write only
hero_layout_draft and stay open; its uploads were already refused by the
multipart lock. Server lock only, as decided: the editor needs no change,
since its status line and the toast already show the demo sentence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-26 09:35:03 -05:00
69998ec548 Merge pull request 'feat(demo): refuse an event step at save with demoRefuses (MODULE_API 1.19.0)' (#224) from feature/demo-step-refusal into main
Some checks failed
sync-project-tree / sync (push) Successful in 25s
Build container images / build (push) Successful in 3m17s
Build container images / deploy (push) Successful in 45s
SonarQube / analysis (push) Failing after 8m36s
Reviewed-on: #224
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-10-26 10:52:08 +00:00
cee4ab8aea feat(demo): refuse an event step at save with demoRefuses (MODULE_API 1.19.0)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 46s
PR Checks / server-tests (pull_request) Successful in 7m0s
PR Checks / client-build (pull_request) Successful in 7m29s
DEMO_MODE.md §3.4 refuses a denied console command when the event is
saved and when it runs. Saving is core's route, and core never asked a
module about a step's params, so the save-time half had no seam (DM18).

An event action may now declare demoRefuses(params), returning a
sentence or null. With DEMO_MODE on, create, save and publish ask it
for every step; the first sentence refuses the write 403 DEMO_LOCKED
with that sentence as the message (and in errors). A hook that throws
refuses too. Off the demo it is validated and never called, and the
catalog strips it with the other callables.

The events controller passes code and message through on create,
update and publish, so the client's one DEMO_LOCKED toast fires.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-26 03:39:42 -05:00
c6f53a1509 Merge pull request 'feat(demo): DEMO_MODE lock, seed:demo and the demo banner (MODULE_API 1.18.0)' (#223) from feature/demo-mode-core into main
Some checks failed
sync-project-tree / sync (push) Successful in 26s
Build container images / build (push) Successful in 2m46s
Build container images / deploy (push) Successful in 50s
SonarQube / analysis (push) Failing after 6m26s
Reviewed-on: #223
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-10-24 00:05:08 +00:00
4ad746c43e feat(demo): DEMO_MODE lock, seed:demo and the demo banner (MODULE_API 1.18.0)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 43s
PR Checks / client-build (pull_request) Successful in 1m3s
PR Checks / server-tests (pull_request) Successful in 8m37s
Core half of the public demo (docs/website/DEMO_MODE.md §10 step 1, website#222).
Nothing here runs unless DEMO_MODE=true.

- server/src/demo/: the lock at /api/v1, ahead of every router. It covers every
  Tier A row of §3.3, the shared-login conditions, POST /auth/register (DM16),
  DELETE /auth/me/sessions/:id on a demo account (DM17), and the PUT
  /admin/settings keys that reach a Tier A lock. Any multipart request is refused
  app-wide. noindex and robots.txt. A condition that cannot decide refuses.
- Loader: demoLocks in module.json (writes only, under the module's own mounts),
  ctx.demoMode, and api.onDemoSeed (DM15). MODULE_API 1.18.0 on both halves.
- GET /public/settings carries demo: { resetsAt, logins } on the demo only.
- npm run seed:demo -- <fixture>: upserts the four accounts, sets the site live
  with registration disabled, seeds content on the first run only, calls each
  module's onDemoSeed, and stamps demo_state. It resolves $env: strings and
  refuses to run off the demo.
- Client: the banner, one DEMO_LOCKED toast fed from the request primitive (so
  module screens get it too), and the published logins on both sign-in pages.
  Dashboard and register say the demo message instead of blaming a role.
- OpenAPI: DemoLocked schema, the demo block, and a note that any write may
  answer DEMO_LOCKED on the demo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-23 18:52:09 -05:00
b5e0d01a91 Merge pull request 'fix(teams): say "game server", not "shard", on the never-synced Teams panel' (#221) from fix/teams-panel-neutral-wording into main
All checks were successful
sync-project-tree / sync (push) Successful in 19s
Build container images / build (push) Successful in 1m55s
Build container images / deploy (push) Successful in 41s
SonarQube / analysis (push) Successful in 10m49s
Reviewed-on: #221
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-10-22 19:59:38 +00:00
8012a457fc fix(teams): say "game server", not "shard", on the never-synced Teams panel
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 39s
PR Checks / client-build (pull_request) Successful in 58s
PR Checks / server-tests (pull_request) Successful in 12m43s
Admin → Teams told an operator that an unsynced projection "is not a
confirmed empty shard". Teams is a core primitive and core's word for the
host is the game server; "shard" is Ultima Online's. The detail now reads
"What is shown below does not confirm that the game server has no Teams."

The matching test, and the four comments that said "shard" about the same
state, follow it. Found while making runicgateway.com's core docs
game-neutral (runicgateway.com#34), which had to paraphrase this line
rather than quote it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-22 14:43:37 -05:00
6890a74c7c Merge pull request 'chore(release): cut edge over to main — Steam sign-in (MODULE_API 1.17.0) and the last fixes' core half' (#220) from edge into main
All checks were successful
Build container images / build (push) Successful in -21s
sync-project-tree / sync (push) Successful in -2m6s
Build container images / deploy (push) Successful in 39s
SonarQube / analysis (push) Successful in 12m57s
Reviewed-on: #220
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-10-22 10:29:20 +00:00
51 changed files with 3226 additions and 36 deletions

View File

@@ -0,0 +1,42 @@
# Capture the demo rig's baseline (docs/website/DEMO_MODE.md §7.2). Run by hand,
# after setting the demo default set up through the demo site: the permission
# groups, a few NPCs, a clan for the demo's Team. It stops the rig (a power stop
# runs `quit`, which saves the world, DM22), compresses oxide/data, oxide/config
# and server/<identity> into demo-baseline.tar.gz at the server root, and starts
# it again. Every wipe from then on restores that archive.
#
# Run it right after a wipe has finished and the default set is in place, not
# while visitors are mid-change: whatever is on the rig is what every later
# cycle returns to.
name: Demo baseline
on:
workflow_dispatch: {}
concurrency:
group: demo-rig
cancel-in-progress: false
env:
DEMO_DIR: ${{ vars.DEMO_DIR || '/opt/demo.runicgateway.com' }}
PTERODACTYL_URL: ${{ vars.PTERODACTYL_URL || 'http://192.168.0.12' }}
PTERODACTYL_SERVER: ${{ vars.PTERODACTYL_SERVER || '6eb9d621' }}
NODE_IMAGE: node:22.23.4-alpine
jobs:
baseline:
runs-on: demo-site
if: github.ref == 'refs/heads/main'
steps:
- name: Check out the deploy files
uses: actions/checkout@v4
- name: Capture the baseline
env:
PTERODACTYL_CLIENT_KEY: ${{ secrets.PTERODACTYL_CLIENT_KEY }}
run: |
set -euo pipefail
docker run --rm -v "$PWD/deploy/demo:/demo:ro" \
-e PTERODACTYL_URL -e PTERODACTYL_SERVER -e PTERODACTYL_CLIENT_KEY \
"$NODE_IMAGE" node /demo/rig.mjs baseline

View File

@@ -0,0 +1,117 @@
# The public demo's wipe (docs/website/DEMO_MODE.md §7). Every 2 hours on the
# hour, UTC (DM7), it puts demo.runicgateway.com and its Rust server back to the
# demo's starting point:
#
# 1. versions the latest Rust bundle and the newest Module-Rust release on the
# same protocol (§7.5, DM12)
# 2. the rig FIRST: kill, write the new bundle if it changed, restore
# demo-baseline.tar.gz, start (§7.2). A fresh site imports what is
# on the rig, so the rig must be back before the site talks to it (§7.1)
# 3. the site down -v, pull, up, seed:demo (§7.3)
# 4. smoke the site answers, then the rig reports in (bounded)
#
# A redeploy is the same path: a push to main that changes deploy/demo/ runs it,
# and so does workflow_dispatch (§7.4).
#
# Runs on the `demo-site` runner, in host mode, on the demo host itself. The host
# needs Docker with Compose, and node for actions/checkout; everything else runs
# in containers. One-time host setup: deploy/demo/README.md.
#
# Secret: PTERODACTYL_CLIENT_KEY the demo subuser's client key (file + power)
# Variables: PTERODACTYL_URL, PTERODACTYL_SERVER (defaults below), DEMO_DIR
name: Demo wipe
on:
schedule:
- cron: '0 */2 * * *'
workflow_dispatch: {}
push:
branches: [main]
paths:
- 'deploy/demo/**'
- '.gitea/workflows/demo-wipe.yml'
# A wipe and a baseline capture must never overlap: both stop the rig.
concurrency:
group: demo-rig
cancel-in-progress: false
env:
DEMO_DIR: ${{ vars.DEMO_DIR || '/opt/demo.runicgateway.com' }}
PTERODACTYL_URL: ${{ vars.PTERODACTYL_URL || 'http://192.168.0.12' }}
PTERODACTYL_SERVER: ${{ vars.PTERODACTYL_SERVER || '6eb9d621' }}
NODE_IMAGE: node:22.23.4-alpine
jobs:
wipe:
runs-on: demo-site
if: github.ref == 'refs/heads/main'
steps:
- name: Check out the deploy files
uses: actions/checkout@v4
- name: Install the deploy files
# Everything but .env, which only the host holds, and state/, which the
# wipe itself writes.
run: |
set -euo pipefail
test -f "$DEMO_DIR/.env" || { echo "::error::$DEMO_DIR/.env is missing; see deploy/demo/README.md"; exit 1; }
mkdir -p "$DEMO_DIR/state" "$DEMO_DIR/logs" "$DEMO_DIR/modules"
# The app runs as uid 1000 and unpacks the declared module into ./modules.
# The runner is not root, so a host chown fails; a container is root and
# the runner may start one. A failure here stops the run: without it the
# app comes up healthy with no module and the seed fails much later.
docker run --rm -v "$DEMO_DIR:/demo" "$NODE_IMAGE" chown -R 1000:1000 /demo/modules /demo/logs
cp deploy/demo/docker-compose.yml deploy/demo/seed.json deploy/demo/rig.mjs "$DEMO_DIR/"
- name: Decide the versions
run: |
set -euo pipefail
docker run --rm -v "$DEMO_DIR:/demo" -e STATE_DIR=/demo/state "$NODE_IMAGE" node /demo/rig.mjs plan
- name: Restore the rig
id: rig
# The site is wiped even when this fails: a demo left un-wiped is worse
# than one whose rig kept a visitor's changes for a cycle. The last step
# fails the run so the failure is seen.
continue-on-error: true
env:
PTERODACTYL_CLIENT_KEY: ${{ secrets.PTERODACTYL_CLIENT_KEY }}
run: |
set -euo pipefail
docker run --rm -v "$DEMO_DIR:/demo" -e STATE_DIR=/demo/state \
-e PTERODACTYL_URL -e PTERODACTYL_SERVER -e PTERODACTYL_CLIENT_KEY \
"$NODE_IMAGE" node /demo/rig.mjs restore
- name: Wipe and reseed the site
run: |
set -euo pipefail
cd "$DEMO_DIR"
docker compose pull
docker compose down -v --remove-orphans
docker compose up -d
for attempt in $(seq 1 60); do
STATUS="$(docker inspect -f '{{.State.Health.Status}}' rg-demo-app-1 2>/dev/null || echo starting)"
[ "$STATUS" = healthy ] && break
sleep 5
done
if [ "$STATUS" != healthy ]; then
echo "::error::the app did not become healthy within 300s"
docker compose logs --tail 100 app
exit 1
fi
docker compose exec -T app npm run --silent --prefix server seed:demo -- /app/deploy/demo/seed.json
docker compose ps
- name: Smoke check
run: |
set -euo pipefail
docker run --rm --network rg-demo_default -v "$DEMO_DIR:/demo" \
-e SITE_URL=http://app:3000 "$NODE_IMAGE" node /demo/rig.mjs smoke
- name: Report the rig restore
if: steps.rig.outcome == 'failure'
run: |
echo "::error::the rig restore failed (see its step); the site was wiped and reseeded anyway"
exit 1

View File

@@ -44,7 +44,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22.23.4
cache: npm
cache-dependency-path: server/package-lock.json
- name: Check core names no module identifier
@@ -90,7 +90,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22.23.4
cache: npm
cache-dependency-path: client/package-lock.json
- name: Install client deps
@@ -113,7 +113,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22.23.4
cache: npm
cache-dependency-path: bot/package-lock.json
- name: Install bot deps

View File

@@ -1,4 +1,4 @@
FROM node:20-alpine
FROM node:22.23.4-alpine
WORKDIR /app

View File

@@ -147,7 +147,7 @@ flowchart TB
| Layer | Tech |
|---|---|
| Backend | Node.js 20+, Express 4, `mariadb` driver (parameterized SQL, no ORM) |
| Backend | Node.js 22 (images pin 22.23.4), Express 4, `mariadb` driver (parameterized SQL, no ORM) |
| Auth | Session service over JWT: httpOnly cookie (web) + bearer access/refresh tokens (mobile), bcrypt hashing, optional TOTP 2FA (`speakeasy` + `qrcode`), pluggable OAuth2/OIDC SSO (built-in Google & Discord + generic) |
| Database | MariaDB 11 (own container) |
| Frontend | React 18, Vite 5, React Router 6 |
@@ -196,7 +196,8 @@ website/
## Prerequisites
- **Node.js 20+** and npm (Node 22/24 are fine).
- **Node.js 22.23.4+** (or 24.22.1+) and npm. Older builds carry the Node2Shell Brotli bug
(CVE-2026-111497), and Node 20 is past end of life.
- **Docker Desktop** (for MariaDB, and for the full Compose deploy).
---

View File

@@ -1,4 +1,4 @@
FROM node:20-alpine
FROM node:22.23.4-alpine
WORKDIR /app/bot

View File

@@ -2,6 +2,8 @@ import { Routes, Route, Navigate, Outlet } from 'react-router-dom'
import { AuthProvider } from './contexts/AuthContext.jsx'
import { SiteProvider } from './contexts/SiteContext.jsx'
import MaintenanceGate from './components/MaintenanceGate.jsx'
import DemoBanner from './components/DemoBanner.jsx'
import DemoToast from './components/DemoToast.jsx'
import RequireAuth from './components/RequireAuth.jsx'
import RequirePlayer from './components/RequirePlayer.jsx'
import RoleGate from './components/RoleGate.jsx'
@@ -90,6 +92,10 @@ export default function App() {
the provider hooks are called once for the whole app rather than
once per screen. */}
<ModuleFeaturesProvider>
{/* The public demo's strip and refusal toast (DEMO_MODE.md §3.1, §6).
Both render nothing unless the server says this is the demo. */}
<DemoBanner />
<DemoToast />
<Routes>
{/* Landing hero — always public, even in maintenance mode. The hero is
itself the pre-launch "coming soon" page, so it sits outside the

View File

@@ -1,5 +1,7 @@
// Thin fetch wrapper. Always sends cookies (httpOnly JWT) and talks to the
// same-origin API (/api/v1) — proxied to the Express server in dev.
import { reportIfDemoLocked } from '../lib/demoNotice.js'
const BASE = '/api/v1'
// Prefix a non-empty query string with "?" (and nothing when it is empty), so
@@ -28,6 +30,9 @@ async function req(path, { method = 'GET', body, headers, raw } = {}) {
const text = await res.text()
const data = text ? safeParse(text) : null
if (!res.ok) {
// The public demo's lock (DEMO_MODE.md §3.1): shown once, as a toast, for
// every caller. The ApiError below still goes to the form as usual.
reportIfDemoLocked(data)
const message = (data && data.message) || res.statusText || 'Request failed'
throw new ApiError(res.status, message, data)
}

View File

@@ -0,0 +1,47 @@
import { Link } from 'react-router-dom'
import { useSite } from '../contexts/SiteContext.jsx'
import { formatResetTime } from '../lib/demoNotice.js'
// The public demo's strip, on every page (docs/website/DEMO_MODE.md §6).
// Renders nothing unless GET /public/settings carries a `demo` block, which the
// server sends only with DEMO_MODE on — so a real shard never draws it, and the
// banner needs no schedule of its own: `resetsAt` is stamped by every wipe.
export default function DemoBanner() {
const { settings } = useSite()
const demo = settings.demo
if (!demo) return null
const at = demo.resetsAt ? formatResetTime(demo.resetsAt) : null
const names = (demo.logins || []).map((l) => l.username)
return (
<div
role="note"
className="sans"
style={{
padding: '7px 16px',
textAlign: 'center',
fontSize: '0.82rem',
lineHeight: 1.45,
color: 'var(--head)',
background: 'var(--bg-deep)',
borderBottom: '1px solid var(--accent)',
}}
>
<strong style={{ color: 'var(--accent)' }}>Demo site</strong>
{' — anything you change is wiped '}
{at ? `at ${at}` : 'every two hours'}.
{names.length > 0 && (
<>
{' Sign-ins: '}
{names.join(' / ')}
{' — passwords on the '}
<Link to="/account/login" style={{ color: 'var(--accent)' }}>
sign-in page
</Link>
.
</>
)}
</div>
)
}

View File

@@ -0,0 +1,51 @@
import { useSite } from '../contexts/SiteContext.jsx'
// The published sign-ins, on the sign-in pages of the public demo
// (DEMO_MODE.md §4). They are public by design — printed here and on
// runicgateway.com/beta — so "Use" fills the form rather than making anyone
// copy a password. Renders nothing off the demo.
export default function DemoLogins({ onPick }) {
const { settings } = useSite()
const logins = settings.demo?.logins || []
if (logins.length === 0) return null
return (
<section
aria-label="Demo sign-ins"
className="sans"
style={{
marginTop: 18,
padding: '14px 16px',
border: '1px solid var(--line)',
borderRadius: 10,
fontSize: '0.84rem',
// Opaque: the admin sign-in page draws it over the hero image.
background: 'var(--bg-deep)',
}}
>
<p style={{ margin: '0 0 10px', color: 'var(--head)' }}>
<strong>Demo sign-ins</strong> <span style={{ color: 'var(--dim)' }}>— shared by everyone, reset every wipe</span>
</p>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<tbody>
{logins.map((l) => (
<tr key={l.username}>
<td style={{ padding: '5px 0', verticalAlign: 'middle' }}>
<div style={{ color: 'var(--head)', whiteSpace: 'nowrap' }}>{l.username}</div>
<div style={{ color: 'var(--dim)', fontFamily: 'monospace', fontSize: '0.78rem', wordBreak: 'break-all' }}>
{l.password}
</div>
</td>
<td style={{ padding: '5px 8px', color: 'var(--dim)', verticalAlign: 'middle' }}>{l.role}</td>
<td style={{ padding: '5px 0', textAlign: 'right', verticalAlign: 'middle' }}>
<button type="button" className="btn btn-ghost" style={{ padding: '3px 10px', fontSize: '0.8rem' }} onClick={() => onPick(l.username, l.password)}>
Use
</button>
</td>
</tr>
))}
</tbody>
</table>
</section>
)
}

View File

@@ -0,0 +1,47 @@
import { useEffect, useState } from 'react'
import { onDemoLocked } from '../lib/demoNotice.js'
// The one toast for a write the public demo refused (DEMO_MODE.md §3.1). Fed
// by api/client.js through lib/demoNotice.js, so every screen — and every
// installed module's screens — gets it without handling the code itself. On a
// real shard nothing is ever refused that way, so it never appears.
const SHOW_MS = 5000
export default function DemoToast() {
const [message, setMessage] = useState(null)
useEffect(() => onDemoLocked((text) => setMessage(text)), [])
useEffect(() => {
if (!message) return undefined
const t = setTimeout(() => setMessage(null), SHOW_MS)
return () => clearTimeout(t)
}, [message])
return (
<div
role="status"
aria-live="polite"
style={{ position: 'fixed', left: 16, right: 16, bottom: 20, zIndex: 1000, display: 'flex', justifyContent: 'center', pointerEvents: 'none' }}
>
{message && (
<div
className="sans"
style={{
maxWidth: 480,
padding: '11px 16px',
borderRadius: 10,
fontSize: '0.88rem',
color: 'var(--head)',
background: 'var(--bg-deep)',
border: '1px solid var(--accent)',
boxShadow: '0 12px 32px rgba(0,0,0,0.45)',
pointerEvents: 'auto',
}}
>
{message}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,57 @@
// The public demo's refusal (docs/website/DEMO_MODE.md §3.1).
//
// With DEMO_MODE on, the server answers a locked write `403 { code:
// 'DEMO_LOCKED', message }`. Every request goes through api/client.js's req(),
// installed modules' included, so req() reports the refusal here and the one
// toast (components/DemoToast.jsx) shows it — wherever the action was, without
// each form learning the code. The form still gets its ApiError as usual.
//
// The lock is the server's (DM10). Nothing here decides what is allowed; it
// only explains what was refused.
export const DEMO_LOCKED = 'DEMO_LOCKED'
const listeners = new Set()
/** Subscribe to refusals. Returns the unsubscribe function. */
export function onDemoLocked(fn) {
listeners.add(fn)
return () => listeners.delete(fn)
}
/** Report a refusal, if this response body is one. Returns whether it was. */
export function reportIfDemoLocked(body) {
if (!body || typeof body !== 'object' || body.code !== DEMO_LOCKED) return false
const message = typeof body.message === 'string' && body.message ? body.message : 'This is turned off on the demo site.'
for (const fn of listeners) {
try {
fn(message)
} catch {
// A broken listener must not turn a refusal into a different error.
}
}
return true
}
/**
* Was this ApiError the demo's refusal? For the few screens that turn a 403
* into a sentence of their own ("only an administrator can…"), which would
* otherwise blame the role of somebody who is signed in as an admin.
*/
export function isDemoLocked(err) {
return Boolean(err && err.body && err.body.code === DEMO_LOCKED)
}
/**
* "14:00 UTC" for the banner. The wipe runs on the hour in UTC (DM7), so the
* banner says UTC rather than converting — a converted time in a reviewer's
* zone reads as a different schedule from the one published on /beta.
*/
export function formatResetTime(iso) {
const ms = Date.parse(iso)
if (Number.isNaN(ms)) return null
const d = new Date(ms)
const hh = String(d.getUTCHours()).padStart(2, '0')
const mm = String(d.getUTCMinutes()).padStart(2, '0')
return `${hh}:${mm} UTC`
}

View File

@@ -41,7 +41,7 @@ export function relativeTime(when, now = Date.now()) {
* Distinct from `teamAdmin.freshnessOf`, which is worded for an operator
* debugging a sync. A visitor needs one sentence about whether what they are
* looking at is current, and specifically must never be shown an unconfirmed
* empty projection as though it were a confirmed empty shard.
* empty projection as though it were a confirmed empty one.
*/
export function freshnessNote(sync = {}, now = Date.now()) {
// Nothing supplies Teams here, so there is nothing to be stale ABOUT. A
@@ -57,7 +57,7 @@ export function freshnessNote(sync = {}, now = Date.now()) {
* Group feed items into days, newest first, preserving order within a day (§4.3).
*
* Keyed by local calendar date rather than by a UTC slice: "yesterday" is a
* property of where the reader is sitting, and a shard's evening raid landing at
* property of where the reader is sitting, and a game server's evening raid landing at
* 00:30 UTC belongs on the day the players experienced it.
*/
export function groupByDay(items = [], locale = undefined) {

View File

@@ -3,7 +3,7 @@
//
// Plain JS with tests, following lib/moduleAdmin.js. The reason it is worth
// splitting here specifically: this screen's job is to tell an operator the
// difference between "the shard has no Teams" and "core has not been able to ask
// difference between "the game server has no Teams" and "core has not been able to ask
// for two hours", and those two produce almost the same page. Getting that
// wording right is logic, not markup.
@@ -31,7 +31,7 @@ export function freshnessOf(sync = {}) {
return {
tone: TONE.bad,
label: 'Never synced',
detail: 'Core has never had an answer it could trust. What is shown below is not a confirmed empty shard.',
detail: 'Core has never had an answer it could trust. What is shown below does not confirm that the game server has no Teams.',
}
}
if (sync.stale) {

View File

@@ -11,6 +11,13 @@
// that the two files can drift, so a test asserts they agree
// (client/test/moduleRegistry.test.js) rather than trusting a bump to remember
// both.
// 1.19.0 — an event action may declare `demoRefuses(params)` (DEMO_MODE.md §3.4,
// DM18): the demo refuses a step at save. Server-side only; the refusal reaches
// the browser as an ordinary DEMO_LOCKED answer. This file bumps for the reason at the top.
// 1.18.0 — the public demo (DEMO_MODE.md §3.2): `demoLocks` in module.json,
// `ctx.demoMode`, `api.onDemoSeed`. Server-side; the client half is core's own
// banner and refusal toast, which an installed module's requests reach through
// the shared request primitive. This file bumps for the reason at the top.
// 1.17.0 — a module may take part in linking a sign-in identity (STEAM_SIGNIN.md
// §4): `ctx.identities.list`, `api.onIdentityLinking`, `api.onIdentityLinked`.
// Server-side; the client half is core's own account pages showing Steam. This
@@ -98,4 +105,4 @@
// but the two halves state ONE version: a module declares a single coreApi range
// and is served one chunk, so a client that claimed 1.0.0 while the server
// answered 1.1.0 would be two answers to one question.
export const MODULE_API_VERSION = '1.17.0'
export const MODULE_API_VERSION = '1.19.0'

View File

@@ -4,6 +4,7 @@ import MoonDot from '../../components/MoonDot.jsx'
import BrandLogo from '../../components/BrandLogo.jsx'
import ProviderIcon from '../../components/ProviderIcon.jsx'
import TrustLimitModal from '../../components/security/TrustLimitModal.jsx'
import DemoLogins from '../../components/DemoLogins.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { useSite } from '../../contexts/SiteContext.jsx'
import { api } from '../../api/client.js'
@@ -342,6 +343,14 @@ export default function AdminLogin() {
Protected area — not indexed. Sessions expire after 1 day.
</p>
</form>
{stage === 'creds' && (
<DemoLogins
onPick={(u, p) => {
setUsername(u)
setPassword(p)
}}
/>
)}
<p style={{ textAlign: 'center', margin: '20px 0 0' }}>
<Link to="/" className="sans" style={{ color: 'var(--accent)', fontSize: '0.84rem', textDecoration: 'none' }}>
← Back to site

View File

@@ -3,6 +3,7 @@ import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useAsync } from '../../../lib/useAsync.js'
import { ago, dateTime } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
import { isDemoLocked } from '../../../lib/demoNotice.js'
import { useSite } from '../../../contexts/SiteContext.jsx'
import { useAuth } from '../../../contexts/AuthContext.jsx'
@@ -49,11 +50,10 @@ export default function Dashboard() {
await refreshSite()
reload()
} catch (err) {
setModeError(
err.status === 403
? 'Only an administrator can change the site mode.'
: 'Could not change the site mode. Try again.',
)
let message = 'Could not change the site mode. Try again.'
if (isDemoLocked(err)) message = err.message
else if (err.status === 403) message = 'Only an administrator can change the site mode.'
setModeError(message)
} finally {
setBusy(false)
}

View File

@@ -14,7 +14,7 @@ import TeamVoice from './TeamVoice.jsx'
// Three panels, in the order an operator needs them:
//
// 1. **Sync state**, verbatim, including the last error. The screen's first job
// is to make "the shard has no Teams" and "core has not been able to ask for
// is to make "the game server has no Teams" and "core has not been able to ask for
// two hours" impossible to confuse — they render almost identically
// otherwise, and one is fine while the other is an outage.
// 2. **The review queue** — Teams auto-hidden because their name matched the

View File

@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { Link, useNavigate, useLocation } from 'react-router-dom'
import ProviderIcon from '../../components/ProviderIcon.jsx'
import TrustLimitModal from '../../components/security/TrustLimitModal.jsx'
import DemoLogins from '../../components/DemoLogins.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { api } from '../../api/client.js'
import PlayerShell, { honeypotStyle } from './PlayerShell.jsx'
@@ -272,6 +273,15 @@ export default function PlayerLogin() {
)}
</form>
{stage === 'creds' && (
<DemoLogins
onPick={(u, p) => {
setUsername(u)
setPassword(p)
}}
/>
)}
{trustLimit && (
<TrustLimitModal
devices={trustLimit.devices}

View File

@@ -3,6 +3,7 @@ import { Link, useNavigate } from 'react-router-dom'
import ProviderIcon from '../../components/ProviderIcon.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { api } from '../../api/client.js'
import { isDemoLocked } from '../../lib/demoNotice.js'
import PlayerShell, { honeypotStyle } from './PlayerShell.jsx'
export default function PlayerRegister() {
@@ -54,6 +55,7 @@ export default function PlayerRegister() {
navigate('/player', { replace: true })
} catch (err) {
if (err.status === 409) setError('That username is already taken.')
else if (isDemoLocked(err)) setError(err.message)
else if (err.status === 403) setError('Registration is not open right now.')
else if (err.status === 400) setError(err.message || 'Please check your details and try again.')
else setError('Could not create your account right now.')

View File

@@ -0,0 +1,71 @@
import { test, afterEach } from 'node:test'
import assert from 'node:assert/strict'
import { api } from '../src/api/client.js'
import { onDemoLocked, reportIfDemoLocked, formatResetTime, isDemoLocked } from '../src/lib/demoNotice.js'
// The public demo's refusal reaches the one toast from req() itself, so every
// screen and every installed module gets it without handling the code — and
// the caller still gets its ApiError, so a form's own error line is unchanged.
const realFetch = global.fetch
afterEach(() => {
global.fetch = realFetch
})
function replyOnce(status, body) {
global.fetch = async () => ({
ok: status >= 200 && status < 300,
status,
statusText: 'Forbidden',
text: async () => JSON.stringify(body),
})
}
test('a DEMO_LOCKED answer is reported once and still thrown to the caller', async () => {
const heard = []
const off = onDemoLocked((m) => heard.push(m))
replyOnce(403, { code: 'DEMO_LOCKED', message: 'This is turned off on the demo site.' })
await assert.rejects(() => api.publicSettings(), (err) => err.status === 403 && err.message === 'This is turned off on the demo site.')
off()
assert.deepEqual(heard, ['This is turned off on the demo site.'])
})
test('any other refusal is not a demo refusal', async () => {
const heard = []
const off = onDemoLocked((m) => heard.push(m))
replyOnce(403, { message: 'Admin role required' })
await assert.rejects(() => api.publicSettings())
off()
assert.deepEqual(heard, [])
})
test('reportIfDemoLocked recognises the code alone and survives a broken listener', () => {
const off1 = onDemoLocked(() => {
throw new Error('boom')
})
const heard = []
const off2 = onDemoLocked((m) => heard.push(m))
assert.equal(reportIfDemoLocked({ code: 'DEMO_LOCKED' }), true)
assert.equal(reportIfDemoLocked({ code: 'OTHER' }), false)
assert.equal(reportIfDemoLocked('text'), false)
assert.equal(reportIfDemoLocked(null), false)
off1()
off2()
assert.deepEqual(heard, ['This is turned off on the demo site.'])
})
test('isDemoLocked tells the demo refusal from a role 403', async () => {
replyOnce(403, { code: 'DEMO_LOCKED', message: 'This is turned off on the demo site.' })
const locked = await api.publicSettings().catch((e) => e)
replyOnce(403, { message: 'Admin role required' })
const role = await api.publicSettings().catch((e) => e)
assert.equal(isDemoLocked(locked), true)
assert.equal(isDemoLocked(role), false)
assert.equal(isDemoLocked(null), false)
})
test('the reset time is said in UTC, as the wipe runs', () => {
assert.equal(formatResetTime('2026-10-24T14:00:00.000Z'), '14:00 UTC')
assert.equal(formatResetTime('2026-10-24T09:05:00+02:00'), '07:05 UTC')
assert.equal(formatResetTime('nope'), null)
})

View File

@@ -19,7 +19,7 @@ test('a deployment with no provider is not stale, it is uninvolved', () => {
assert.equal(freshnessNote({ configured: false }, NOW), null)
})
test('never synced is a warning, and never reads as a confirmed empty shard', () => {
test('never synced is a warning, and never reads as a confirmed empty roster', () => {
const note = freshnessNote({ configured: true, lastSyncAt: null }, NOW)
assert.equal(note.tone, 'warn')
assert.match(note.text, /Not yet confirmed/)

View File

@@ -21,13 +21,13 @@ test('no provider is idle, not a fault', () => {
assert.match(f.label, /No Team provider/)
})
test('never synced is reported as never synced, not as an empty shard', () => {
test('never synced is reported as never synced, not as a game server with no Teams', () => {
// The failure this prevents: an empty projection core has never confirmed,
// rendered as though the game genuinely has no Teams.
const f = freshnessOf({ configured: true, lastSyncAt: null })
assert.equal(f.tone, TONE.bad)
assert.equal(f.label, 'Never synced')
assert.match(f.detail, /not a confirmed empty shard/)
assert.match(f.detail, /does not confirm that the game server has no Teams/)
})
test('stale says how old it is', () => {

61
deploy/demo/.env.example Normal file
View File

@@ -0,0 +1,61 @@
# ─── The demo site's environment (docs/website/DEMO_MODE.md) ───
# Copy to /opt/demo.runicgateway.com/.env on the `demo-site` host and fill in.
# NEVER commit the real file. The wipe workflow never writes it; it only reads it.
IMAGE_TAG=latest
NODE_ENV=production
PORT=3000
# The host port the external reverse proxy forwards demo.runicgateway.com to.
DEMO_HOST_PORT=3000
# How often the wipe runs, for the banner's "wiped at" time. Must match the cron
# in .gitea/workflows/demo-wipe.yml (every 2 hours on the hour, UTC, DM7).
DEMO_RESET_EVERY_HOURS=2
# Branding
BRAND_NAME=Runic Gateway Demo
BRAND_SHORT_NAME=RG Demo
BRAND_URL=https://demo.runicgateway.com
CLIENT_ORIGIN=https://demo.runicgateway.com
# Database. Recreated empty at every wipe; the passwords only need to be stable.
DB_HOST=db
DB_PORT=3306
DB_NAME=runic_gateway
DB_USER=runic
DB_PASSWORD=
DB_ROOT_PASSWORD=
# Long random strings, e.g. `openssl rand -hex 32`. SECRET_ENC_KEY is required in
# production. Every stored secret is re-seeded at each wipe, so rotating either
# one only signs people out.
JWT_SECRET=
SECRET_ENC_KEY=
COOKIE_SECURE=auto
# The guard on the app's unpublished internal port. The demo runs no bot, but the
# app refuses to start in production without it. Another long random string.
BOT_INTERNAL_KEY=
# One proxy hop: the external reverse proxy.
TRUST_PROXY=1
# Leave ADMIN_USERNAME unset: the boot seed must not create a fifth admin. The four
# demo accounts come from seed.json (§4).
ADMIN_USERNAME=
# Where modules may be installed from. A fresh database seeds the allowlist from
# this, and the declared module (state/modules.env) installs under it.
MODULE_SOURCE_HOSTS=gitea.whitlocktech.com
# ── seed.json reads these through "$env:NAME" ──
# The published logins (§4) are not secrets, but they live here so the repo does
# not pin them. play-review's is NOT published; it goes in the Play Console only.
DEMO_ADMIN_PASSWORD=
DEMO_MODERATOR_PASSWORD=
DEMO_PLAYER_PASSWORD=
PLAY_REVIEW_PASSWORD=
# A stand-in Steam id that has played on the rig, so demo-player's Rust screens
# have data (DM6).
DEMO_PLAYER_STEAM_ID=
# The rig's sidecar web API (rust-oxide's last allocation) and its web token: the
# RUSTLINK_WEB_TOKEN in the rig's startup command, or rust-link/sidecar.toml.
DEMO_RIG_SIDECAR_URL=http://192.168.0.12:21004
DEMO_RIG_SIDECAR_TOKEN=

53
deploy/demo/README.md Normal file
View File

@@ -0,0 +1,53 @@
# deploy/demo — demo.runicgateway.com
The public demo site: the normal image with `DEMO_MODE=true`, wiped every two hours together
with its Rust server. Design of record: `docs/website/DEMO_MODE.md`.
| File | What it is |
| --- | --- |
| `docker-compose.yml` | The app and its database. No bot and no ntfy: the demo sends nothing outward (§5) |
| `.env.example` | Every variable the host's `.env` needs, with what each one is for |
| `seed.json` | The fixture `seed:demo` writes after every wipe: the four logins, settings, content, and the Rust module's server link and Steam link (§7.3). Passwords and credentials are `"$env:NAME"` references to `.env` |
| `rig.mjs` | Versions (`plan`), the rig restore (`restore`), the baseline capture (`baseline`) and the smoke check (`smoke`), against the Pterodactyl client API (§7.2, §7.5) |
Two workflows drive it, both on the `demo-site` runner:
- **`demo-wipe.yml`**: every 2 hours on the hour (UTC), on a push to `main` that changes this
directory, and by hand. Versions, then the rig, then the site, then a smoke check.
- **`demo-baseline.yml`**: by hand only. It captures the rig's current state as
`demo-baseline.tar.gz`, which every wipe restores.
## One-time host setup
1. The `demo-site` runner in **host mode** on the demo host, with Docker and Compose, and `node`
on the `PATH` (for `actions/checkout`). The host must reach `gitea.whitlocktech.com`, the
Pterodactyl panel and the rig's sidecar port.
2. `sudo mkdir -p /opt/demo.runicgateway.com`, owned by the runner's user (or the runner runs as
root). To use another path, set the `DEMO_DIR` Actions variable.
3. Copy `.env.example` to `/opt/demo.runicgateway.com/.env` and fill every blank. Generate
secrets on the host, e.g. `openssl rand -hex 32`. The file never leaves the host.
4. Log the host in to the registry if the images are not public: `docker login gitea.whitlocktech.com`.
5. Point the external reverse proxy at `<host>:DEMO_HOST_PORT` (default 3000).
6. Run **Demo wipe** by hand. The first run finds no baseline, so it leaves the rig as it is and
starts it.
Then set the demo default set up through the site: the permission groups, a few NPCs, a clan. Run
**Demo baseline** while nobody else is changing anything. Every later wipe returns the rig to that.
The panel URL and the rig's server identifier default to `http://192.168.0.12` and `6eb9d621`
(`rust-oxide`). Override them with the `PTERODACTYL_URL` and `PTERODACTYL_SERVER` Actions
variables. `PTERODACTYL_CLIENT_KEY` is the demo subuser's key, an Actions secret.
## What the wipe keeps on the host
`state/` in the deploy directory:
- `bundle.json`: the Rust bundle last written onto the rig. A new bundle is written only when
this differs.
- `modules.env`: `MODULES=rust@<version>=<manifest>`, the newest Module-Rust release on the bundle's
protocol. When no release matches a new bundle yet, the wipe keeps this file and the rig's
bundle as they were and warns (§7.5).
- `plan.json`: this cycle's decision, from `plan` to `restore`.
`modules/` keeps the unpacked module between wipes. `down -v` drops the database and the uploads,
never this directory.

View File

@@ -0,0 +1,70 @@
# The public demo, demo.runicgateway.com (docs/website/DEMO_MODE.md).
#
# The same image as every other deployment, switched by DEMO_MODE=true (§2). Only
# the app and its database: no bot and no ntfy, because the demo sends nothing
# outward (§5). The wipe workflow (.gitea/workflows/demo-wipe.yml) copies this
# file into the deploy directory on the `demo-site` host and runs it from there;
# the host's own `.env` beside it holds every secret.
#
# The project name is fixed so the network is `rg-demo_default` whatever the
# directory is called: the workflow's smoke check joins it to reach the app.
name: rg-demo
services:
db:
image: mariadb:11
restart: unless-stopped
environment:
MARIADB_DATABASE: ${DB_NAME}
MARIADB_USER: ${DB_USER}
MARIADB_PASSWORD: ${DB_PASSWORD}
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
volumes:
# Dropped at every wipe (`down -v`) with the uploads volume: that is the wipe.
- dbdata:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 10
app:
image: gitea.whitlocktech.com/runicgateway/website-app:${IMAGE_TAG:-latest}
restart: unless-stopped
# modules.env is written by the wipe (rig.mjs plan): MODULES=rust@<version>=…,
# the newest Module-Rust release on the bundle's protocol (§7.5). Kept out of
# .env so the workflow never edits the file holding the secrets.
env_file:
- .env
- state/modules.env
environment:
DEMO_MODE: "true"
DB_HOST: db
UPLOAD_DIR: /app/uploads
LOG_DIR: /app/logs
MODULES_DIR: /app/modules
depends_on:
db:
condition: service_healthy
volumes:
- uploads:/app/uploads
- ./logs:/app/logs
# A bind mount, not a volume, so `down -v` keeps the unpacked module and a
# wipe does not download it again. Must be writable by uid 1000.
- ./modules:/app/modules
# The seed fixture (§7.3), read by `seed:demo` after every wipe.
- ./seed.json:/app/deploy/demo/seed.json:ro
# Busybox wget is in the Alpine image; `/status` answers once the server listens.
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:3000/api/v1/public/status"]
interval: 10s
timeout: 5s
start_period: 30s
retries: 30
# The external reverse proxy (DNS + HTTPS) forwards here.
ports:
- "${DEMO_HOST_PORT:-3000}:3000"
volumes:
dbdata:
uploads:

372
deploy/demo/rig.mjs Normal file
View File

@@ -0,0 +1,372 @@
// ── The demo's rig and versions (docs/website/DEMO_MODE.md §7.2, §7.5) ──────
//
// One script, four commands, run by the demo workflows on the `demo-site` host
// inside a stock Node container (so the host needs Docker and nothing else):
//
// node rig.mjs plan decide the versions for this cycle (§7.5)
// node rig.mjs restore put the rig back to the baseline, updating its plugins
// and sidecar first when the bundle changed (§7.2)
// node rig.mjs baseline capture the rig's current state as the baseline (§7.2)
// node rig.mjs smoke wait for the site, then for the rig to report in (§7.3.6)
//
// State lives in $STATE_DIR (the deploy directory's `state/`):
// bundle.json the bundle last written onto the rig
// modules.env MODULES=rust@<version>=<manifest>, read by docker-compose.yml
// plan.json this cycle's decision, written by `plan`, read by `restore`
//
// Environment:
// PTERODACTYL_URL the panel, e.g. http://192.168.0.12
// PTERODACTYL_SERVER the rig's short identifier, e.g. e6758c06
// PTERODACTYL_CLIENT_KEY the demo subuser's client key (file + power only)
// PTERODACTYL_IDENTITY optional: the folder under /server holding the save;
// found by listing /server when unset
// GITEA_URL default https://gitea.whitlocktech.com
// SITE_URL for `smoke`, e.g. http://app:3000
// SMOKE_TIMEOUT_SECONDS for `smoke`, default 900 (a Rust boot is ~9 minutes)
//
// Dependency-free on purpose: Node's own fetch and crypto, and the image's tar.
import { createHash } from 'node:crypto'
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
const GITEA = (process.env.GITEA_URL || 'https://gitea.whitlocktech.com').replace(/\/+$/, '')
const STATE_DIR = process.env.STATE_DIR || path.join(path.dirname(fileURLToPath(import.meta.url)), 'state')
const BASELINE = 'demo-baseline.tar.gz'
const MODULE_REPO = 'RunicGateway/Module-Rust'
const BUNDLE_PATH = 'RunicGateway/installer/contents/v2/rust/current.json?ref=bundles'
const log = (...a) => console.log(...a)
const warn = (msg) => console.log(`::warning::${msg}`)
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
function need(name) {
const v = process.env[name]
if (!v) throw new Error(`${name} is not set`)
return v
}
const statePath = (f) => path.join(STATE_DIR, f)
function readJson(file, fallback = null) {
try {
return JSON.parse(fs.readFileSync(file, 'utf8'))
} catch {
return fallback
}
}
function writeFile(file, text) {
fs.mkdirSync(path.dirname(file), { recursive: true })
fs.writeFileSync(file, text)
}
// ── Gitea ──────────────────────────────────────────────────────────────────
// The JSON API, never /raw/: /raw/ is CDN-cached for hours on this instance, and
// a stale current.json would pin the demo to an old bundle without a word.
async function gitea(apiPath) {
const res = await fetch(`${GITEA}/api/v1/repos/${apiPath}`, { headers: { Accept: 'application/json' } })
if (!res.ok) throw new Error(`GET ${apiPath}: HTTP ${res.status}`)
return res.json()
}
async function currentBundle() {
const file = await gitea(BUNDLE_PATH)
return JSON.parse(Buffer.from(file.content, 'base64').toString('utf8'))
}
async function download(url, sha256) {
const res = await fetch(url)
if (!res.ok) throw new Error(`GET ${url}: HTTP ${res.status}`)
const buf = Buffer.from(await res.arrayBuffer())
if (sha256) {
const got = createHash('sha256').update(buf).digest('hex')
if (got !== sha256) throw new Error(`${url}: sha256 ${got} does not match the bundle's ${sha256}`)
}
return buf
}
const semver = (v) => String(v).replace(/^v/, '').split('.').map((n) => Number.parseInt(n, 10) || 0)
function newerFirst(a, b) {
const x = semver(a)
const y = semver(b)
for (let i = 0; i < 3; i += 1) if (x[i] !== y[i]) return y[i] - x[i]
return 0
}
/** The newest Module-Rust release whose install manifest says `protocol` (DM12). */
async function moduleFor(protocol) {
const releases = (await gitea(`${MODULE_REPO}/releases?limit=50`))
.filter((r) => !r.draft && !r.prerelease)
.sort((a, b) => newerFirst(a.tag_name, b.tag_name))
for (const r of releases) {
const asset = (r.assets || []).find((a) => /^module-rust-.+\.json$/.test(a.name))
if (!asset) continue
const res = await fetch(asset.browser_download_url)
if (!res.ok) continue
const manifest = await res.json()
// Releases from before §7.5 carry no `protocol`; they never match.
if (manifest.protocol === protocol) {
return { version: manifest.version, manifestUrl: asset.browser_download_url }
}
}
return null
}
// ── Pterodactyl (client API: files + power only) ─────────────────────────
function ptero() {
const base = `${need('PTERODACTYL_URL').replace(/\/+$/, '')}/api/client/servers/${need('PTERODACTYL_SERVER')}`
const key = need('PTERODACTYL_CLIENT_KEY')
async function call(method, sub, { json, body, type, raw = false } = {}) {
const headers = { Authorization: `Bearer ${key}`, Accept: 'application/json' }
let payload
if (json !== undefined) {
headers['Content-Type'] = 'application/json'
payload = JSON.stringify(json)
} else if (body !== undefined) {
headers['Content-Type'] = type || 'application/octet-stream'
payload = body
}
const res = await fetch(`${base}${sub}`, { method, headers, body: payload })
const text = await res.text()
if (!res.ok) throw new Error(`${method} ${sub}: HTTP ${res.status} ${text.slice(0, 300)}`)
if (raw) return text
return text ? JSON.parse(text) : null
}
return {
state: async () => (await call('GET', '/resources')).attributes.current_state,
power: (signal) => call('POST', '/power', { json: { signal } }),
list: async (dir) =>
(await call('GET', `/files/list?directory=${encodeURIComponent(dir)}`)).data.map((f) => f.attributes),
remove: (files) => call('POST', '/files/delete', { json: { root: '/', files } }),
decompress: (file) => call('POST', '/files/decompress', { json: { root: '/', file } }),
compress: async (files) => (await call('POST', '/files/compress', { json: { root: '/', files } })).attributes,
rename: (from, to) => call('PUT', '/files/rename', { json: { root: '/', files: [{ from, to }] } }),
read: (file) => call('GET', `/files/contents?file=${encodeURIComponent(file)}`, { raw: true }),
write: (file, body, type) => call('POST', `/files/write?file=${encodeURIComponent(file)}`, { body, type }),
// One file per call: a chmod naming two applies only the first, silently.
chmod: (dir, file, mode) => call('POST', '/files/chmod', { json: { root: dir, files: [{ file, mode }] } }),
}
}
async function waitFor(p, wanted, seconds) {
const until = Date.now() + seconds * 1000
for (;;) {
const s = await p.state()
if (s === wanted) return
if (Date.now() > until) throw new Error(`the rig is "${s}" after ${seconds}s, expected "${wanted}"`)
await sleep(5000)
}
}
/** The folder under /server holding the world save and cfg/. */
async function identity(p) {
if (process.env.PTERODACTYL_IDENTITY) return process.env.PTERODACTYL_IDENTITY
const dirs = (await p.list('/server')).filter((f) => !f.is_file).map((f) => f.name)
if (dirs.length !== 1) {
throw new Error(`cannot tell the server identity: /server holds ${JSON.stringify(dirs)}; set PTERODACTYL_IDENTITY`)
}
return dirs[0]
}
const baselineDirs = (id) => ['oxide/data', 'oxide/config', `server/${id}`]
// ── plan ──────────────────────────────────────────────────────────────────
async function plan() {
const bundle = await currentBundle()
const applied = readJson(statePath('bundle.json'))
const previousModules = fs.existsSync(statePath('modules.env'))
log(`bundle ${bundle.bundle} (protocol ${bundle.protocol}); on the rig: ${applied ? applied.bundle : 'none recorded'}`)
const module = await moduleFor(bundle.protocol)
if (!module) {
// §7.5: a half-released protocol bump never takes the demo down.
if (!previousModules) {
throw new Error(`no Module-Rust release speaks protocol ${bundle.protocol}, and there is no previous set to keep`)
}
warn(`no Module-Rust release speaks protocol ${bundle.protocol} yet; keeping the previous bundle and module`)
writeFile(statePath('plan.json'), JSON.stringify({ bundle: null }, null, 2))
return
}
log(`module rust ${module.version} (protocol ${bundle.protocol})`)
writeFile(statePath('modules.env'), `MODULES=rust@${module.version}=${module.manifestUrl}\n`)
const changed = !applied || applied.bundle !== bundle.bundle
writeFile(statePath('plan.json'), JSON.stringify({ bundle: changed ? bundle : null }, null, 2))
log(changed ? 'the rig gets the new bundle at this wipe' : 'the rig is already on this bundle')
}
// ── restore ───────────────────────────────────────────────────────────────
async function writeBundle(p, bundle) {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'bundle-'))
try {
const plugins = []
for (const part of [bundle.payload, bundle.npc].filter(Boolean)) {
const tarball = path.join(tmp, part.asset.name)
fs.writeFileSync(tarball, await download(part.asset.url, part.asset.sha256))
const out = path.join(tmp, `${part.asset.name}.d`)
fs.mkdirSync(out)
execFileSync('tar', ['-xzf', tarball, '-C', out])
for (const dir of fs.readdirSync(out)) {
for (const f of fs.readdirSync(path.join(out, dir))) {
if (f.endsWith('.cs')) plugins.push({ name: f, text: fs.readFileSync(path.join(out, dir, f)) })
}
}
}
if (!plugins.length) throw new Error(`bundle ${bundle.bundle} carries no plugin files`)
const sidecar = bundle.sidecar.assets['linux-x86_64']
const binary = await download(sidecar.url, sidecar.sha256)
const launcher = await download(bundle.sidecar.launcher.url, bundle.sidecar.launcher.sha256)
for (const f of plugins) {
await p.write(`/oxide/plugins/${f.name}`, f.text, 'text/plain')
log(` wrote oxide/plugins/${f.name}`)
}
await p.write('/rust-link/rust-link-sidecar', binary)
await p.write('/rust-link/with-sidecar.sh', launcher, 'text/plain')
await p.chmod('/rust-link', 'rust-link-sidecar', '755')
await p.chmod('/rust-link', 'with-sidecar.sh', '755')
log(` wrote the sidecar ${bundle.sidecar.version} and its launcher`)
// The rig's own record of what it runs, in the shape the rig push writes, so
// anyone reading the rig sees the bundle the wipe put there.
const prior = JSON.parse((await p.read('/rust-link/bundle.json').catch(() => null)) || '{}')
const record = {
bundle: bundle.bundle,
protocol: bundle.protocol,
framework: prior.framework || 'oxide',
installed: new Date().toISOString().replace(/\.\d+Z$/, 'Z'),
sidecar: { tag: bundle.sidecar.tag },
plugin: { tag: bundle.payload.tag, commit: bundle.payload.commit },
...(bundle.npc ? { runicnpc: { tag: bundle.npc.tag, api: bundle.npc.api } } : {}),
}
await p.write('/rust-link/bundle.json', `${JSON.stringify(record, null, 2)}\n`, 'text/plain')
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
}
async function restore() {
const p = ptero()
const decided = readJson(statePath('plan.json'))
if (!decided) throw new Error('no plan.json; run `plan` first')
// 1. Kill: no save, the world is about to be replaced.
if ((await p.state()) !== 'offline') {
await p.power('kill')
await waitFor(p, 'offline', 120)
}
log('the rig is stopped')
// §7.5: the bundle goes on while the server is stopped.
if (decided.bundle) {
log(`writing bundle ${decided.bundle.bundle} onto the rig`)
await writeBundle(p, decided.bundle)
writeFile(
statePath('bundle.json'),
JSON.stringify({ bundle: decided.bundle.bundle, protocol: decided.bundle.protocol, applied: new Date().toISOString() }, null, 2),
)
}
// 2-3. Delete the three directories and unpack the baseline over them.
const root = (await p.list('/')).map((f) => f.name)
if (root.includes(BASELINE)) {
const id = await identity(p)
// Only what exists: a visitor cannot delete these, but a half-finished
// earlier run can have.
const oxide = new Set((await p.list('/oxide')).map((f) => `oxide/${f.name}`))
const dirs = baselineDirs(id).filter((d) => oxide.has(d) || d === `server/${id}`)
await p.remove(dirs)
log(` deleted ${dirs.join(', ')}`)
await p.decompress(BASELINE)
log(` unpacked ${BASELINE}`)
} else {
warn(`there is no ${BASELINE} on the rig yet; it starts as it is. Capture one with the "Demo baseline" workflow.`)
}
// 4. Start. Rust takes minutes to boot; the site does not wait for it.
await p.power('start')
log('the rig is starting')
}
// ── baseline ──────────────────────────────────────────────────────────────
async function baseline() {
const p = ptero()
// A power stop runs the egg's stop command, `quit`, which writes the save, so
// the subuser needs no console permission (DM22).
if ((await p.state()) !== 'offline') {
await p.power('stop')
await waitFor(p, 'offline', 300)
}
log('the rig is stopped and saved')
const id = await identity(p)
const made = await p.compress(baselineDirs(id))
log(` compressed ${baselineDirs(id).join(', ')} into ${made.name}`)
if ((await p.list('/')).some((f) => f.name === BASELINE)) await p.remove([BASELINE])
await p.rename(made.name, BASELINE)
log(` saved as ${BASELINE} (${made.size} bytes)`)
await p.power('start')
log('the rig is starting')
}
// ── smoke ─────────────────────────────────────────────────────────────────
async function smoke() {
const site = need('SITE_URL').replace(/\/+$/, '')
const until = Date.now() + (Number(process.env.SMOKE_TIMEOUT_SECONDS) || 900) * 1000
const get = async (p) => {
try {
const res = await fetch(`${site}${p}`)
return res.ok ? await res.json() : null
} catch {
return null
}
}
while (!(await get('/api/v1/public/status'))) {
if (Date.now() > until) throw new Error('the site never answered /api/v1/public/status')
await sleep(5000)
}
log('the site answers')
for (;;) {
const list = (await get('/api/v1/public/rust/servers'))?.servers || []
if (list.length && list.every((s) => s.online)) {
log(`the rig reports in: ${list.map((s) => s.name || s.id).join(', ')}`)
return
}
if (Date.now() > until) {
throw new Error(`the rig has not reported in (${JSON.stringify(list.map((s) => ({ id: s.id, online: s.online })))}); the site serves with it shown offline`)
}
await sleep(15000)
}
}
// ── main ──────────────────────────────────────────────────────────────────
export { ptero, identity, baselineDirs, writeBundle, moduleFor, newerFirst, currentBundle, BASELINE }
// Run only when invoked, so a test can import the pieces without running one.
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
const commands = { plan, restore, baseline, smoke }
const command = commands[process.argv[2]]
if (!command) {
console.error(`usage: node rig.mjs ${Object.keys(commands).join('|')}`)
process.exit(2)
}
command().catch((err) => {
console.log(`::error::${err.message}`)
process.exit(1)
})
}

158
deploy/demo/seed.json Normal file
View File

@@ -0,0 +1,158 @@
{
"accounts": {
"demo-admin": { "role": "admin", "password": "$env:DEMO_ADMIN_PASSWORD" },
"demo-moderator": { "role": "moderator", "password": "$env:DEMO_MODERATOR_PASSWORD" },
"demo-player": { "role": "player", "password": "$env:DEMO_PLAYER_PASSWORD" },
"play-review": { "role": "admin", "password": "$env:PLAY_REVIEW_PASSWORD", "published": false }
},
"settings": {
"site_title": "Runic Gateway Demo",
"status_message": "A public demo. Look around, change things — every change is wiped every two hours."
},
"content": {
"posts": [
{
"category": "news",
"title": "Welcome to the Runic Gateway demo",
"slug": "welcome-to-the-demo",
"body": "<p>This is a live Runic Gateway site, linked to a real Rust server. Sign in with one of the logins on the sign-in page and look around: the admin panel, the moderation queue, a player's own pages.</p><p>You can change most things. A few would break the demo for the next visitor — uploads, the server link, deleting accounts — and those answer <em>This is turned off on the demo site</em>. Everything is put back every two hours.</p>"
},
{
"category": "news",
"title": "What the Rust module adds",
"slug": "what-the-rust-module-adds",
"body": "<p>The server list, the kill feed and the leaderboard come from the game as it happens. Signed in as an admin you can manage permission groups, place NPCs, edit plugin configs and build events that run on the server.</p>"
}
],
"wiki": [
{
"slug": "about-this-demo",
"title": "About this demo",
"category": "guides",
"published": true,
"body": "<h2>The logins</h2><p>The sign-in page lists them, with a button that fills the form: an admin, a moderator and a player.</p><h2>The wipe</h2><p>The site and its Rust server go back to this starting point every two hours, on the hour, UTC. The banner says when the next one is. Anyone signed in is signed out by it.</p><h2>What is turned off</h2><p>Anything that would break the demo for the next visitor: uploads, the contact form, changing the shared logins, the site mode, the server link, and console commands that would stop the server.</p>"
}
],
"events": [
{
"title": "Server greeting",
"summary": "Says hello in the game chat. Run it from Admin → Events.",
"timezone": "UTC",
"spec": {
"schedule": { "kind": "manual" },
"phases": [
{
"key": "main",
"label": "Greet",
"steps": [
{
"actionId": "rust.command.run",
"params": {
"server": "demo",
"commands": ["say Hello from the Runic Gateway demo site!"],
"confirm": true
}
}
]
}
]
},
"publish": true
},
{
"title": "Treasure drop",
"summary": "Starts a Dangerous Treasures event: a guarded, burning chest somewhere on the map. Run it from Admin → Events.",
"timezone": "UTC",
"spec": {
"schedule": { "kind": "manual" },
"phases": [
{
"key": "main",
"label": "Drop the treasure",
"steps": [
{ "actionId": "rust.dt.start", "params": { "server": "demo", "level": 0 } }
]
}
]
},
"publish": true
},
{
"title": "Payday",
"summary": "Pays every linked player 500 coins through Economics. Run it from Admin → Events.",
"timezone": "UTC",
"spec": {
"schedule": { "kind": "manual" },
"phases": [
{
"key": "main",
"label": "Pay",
"steps": [
{
"actionId": "rust.coins.pay",
"params": {
"server": "demo",
"amount": 500,
"recipients": "linked",
"message": "Payday! You received {amount} coins.",
"delivery": "chat"
}
}
]
}
]
},
"publish": true
}
]
},
"modules": {
"rust": {
"servers": [
{
"id": "demo",
"name": "Demo server",
"sidecarBaseUrl": "$env:DEMO_RIG_SIDECAR_URL",
"sidecarToken": "$env:DEMO_RIG_SIDECAR_TOKEN"
}
],
"links": [
{ "username": "demo-player", "steamId": "$env:DEMO_PLAYER_STEAM_ID", "serverId": "demo" }
],
"npcs": {
"factions": [{ "a": "bandits", "b": "guards", "relation": "allied" }],
"profiles": [
{
"name": "bandit",
"servers": ["demo"],
"body": {
"kits": ["rnpc_raider"],
"names": ["Bandit", "Bandit Scout"],
"role": "roamer",
"movement": { "mode": "wander", "radius": 20 },
"health": 150,
"faction": "bandits"
}
},
{
"name": "guard",
"servers": ["demo"],
"body": {
"kits": ["rnpc_campguard"],
"names": ["Camp Guard"],
"prefab": "scientistnpc_heavy",
"role": "sentry",
"health": 300,
"faction": "guards",
"kitUse": { "heal": true, "grenades": false, "melee": false, "rockets": false, "flamethrower": false }
}
}
]
},
"chat": [
{ "serverId": "demo", "group": "vip", "style": { "Title": "[VIP]", "TitleColor": "#ffaa00", "UsernameColor": "#ffaa00", "Priority": "10" } },
{ "serverId": "demo", "group": "moderator", "style": { "Title": "[MOD]", "TitleColor": "#55ff55", "UsernameColor": "#55ff55", "Priority": "50" } }
]
}
}
}

284
server/db/seedDemo.js Normal file
View File

@@ -0,0 +1,284 @@
// ── `npm run seed:demo` — the public demo's base (DEMO_MODE.md §7.3) ────────
//
// Run by the wipe workflow inside the app container, after `compose down -v` and
// a fresh `up` have left an empty database with the schema in place:
//
// docker compose exec app npm run seed:demo -- /app/deploy/demo/seed.json
//
// It writes, from one fixture file:
// 1. the four static logins (§4) — upserted, so a password a visitor somehow
// changed is back within one cycle and a re-run is harmless;
// 2. the site settings the demo starts from (live, registration disabled, and
// whatever the fixture adds);
// 3. the seeded content — posts, wiki pages, pages, events — on the first run
// against a database only, so a re-run never duplicates it;
// 4. each installed module's own rows, through the module's `onDemoSeed` hook
// (API 1.18.0, DM15): core never writes a module's tables;
// 5. `demo_state` — when the next wipe is due and the published logins, for
// the banner and the sign-in page (§6).
//
// With the events it also switches on, in the events switchboard, every action
// those events' steps use — otherwise a seeded event cannot run (§7.3).
//
// A string `"$env:NAME"` anywhere in the fixture is replaced with that variable,
// so the rig's sidecar token lives in the host's .env and never in the repo. An
// unset variable fails the run rather than seeding an empty credential.
//
// Refuses to run without DEMO_MODE=true: pointed at a real shard by mistake, it
// would reset four accounts' passwords and flip the site live.
require('dotenv').config()
const fs = require('fs')
const path = require('path')
const { isDemo, DEMO_ACCOUNTS } = require('../src/demo/mode')
const ROLES = ['admin', 'editor', 'moderator', 'player']
const log = require('../src/utils/logger')('seed-demo')
// ── Pure helpers (tested in test/seedDemo.test.js) ─────────────────────────
/** Replace every `"$env:NAME"` string, at any depth. Throws on an unset one. */
function resolveEnv(value, env = process.env, where = 'fixture') {
if (typeof value === 'string') {
const m = /^\$env:([A-Z_][A-Z0-9_]*)$/.exec(value)
if (!m) return value
const got = env[m[1]]
if (got === undefined || got === '') throw new Error(`${where}: environment variable ${m[1]} is not set`)
return got
}
if (Array.isArray(value)) return value.map((v, i) => resolveEnv(v, env, `${where}[${i}]`))
if (value && typeof value === 'object') {
const out = {}
for (const [k, v] of Object.entries(value)) out[k] = resolveEnv(v, env, `${where}.${k}`)
return out
}
return value
}
/**
* When the next wipe is due: the next whole multiple of `everyHours` past
* midnight UTC, strictly after `now` — the schedule the wipe workflow's cron
* runs on (DM7: every 2 hours on the hour). The seed runs a minute or two after
* a wipe starts, so "strictly after" lands on the following slot.
*/
function nextResetAt(now = new Date(), everyHours = 2) {
const step = everyHours * 60 * 60 * 1000
const midnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
const slots = Math.floor((now.getTime() - midnight) / step) + 1
return new Date(midnight + slots * step)
}
/** Check the fixture's shape before anything is written. Returns a list of problems. */
function checkFixture(fixture) {
const problems = []
if (!fixture || typeof fixture !== 'object' || Array.isArray(fixture)) return ['the fixture must be a JSON object']
const accounts = fixture.accounts || {}
for (const name of DEMO_ACCOUNTS) {
const a = accounts[name]
if (!a) {
problems.push(`accounts.${name} is missing`)
continue
}
if (typeof a.password !== 'string' || a.password.length < 8) {
problems.push(`accounts.${name}.password must be a string of at least 8 characters`)
}
if (!ROLES.includes(a.role)) problems.push(`accounts.${name}.role must be one of ${ROLES.join(', ')}`)
}
for (const name of Object.keys(accounts)) {
if (!DEMO_ACCOUNTS.includes(name)) {
problems.push(`accounts.${name} is not a demo account (the lock only knows ${DEMO_ACCOUNTS.join(', ')})`)
}
}
if (fixture.settings !== undefined) {
if (!fixture.settings || typeof fixture.settings !== 'object' || Array.isArray(fixture.settings)) {
problems.push('settings must be an object')
} else if ('demo_state' in fixture.settings) {
problems.push('settings.demo_state is written by the seed itself')
}
}
for (const key of ['posts', 'wiki', 'pages', 'events']) {
const list = fixture.content?.[key]
if (list !== undefined && !Array.isArray(list)) problems.push(`content.${key} must be an array`)
}
if (fixture.modules !== undefined && (typeof fixture.modules !== 'object' || Array.isArray(fixture.modules))) {
problems.push('modules must be an object keyed by module id')
}
return problems
}
/**
* Every action a seeded event's steps use, once each, in first-use order.
*
* A fresh database has no switchboard rows, so every `change` and `irreversible`
* action starts disabled (EVENTS.md §K) — and a seeded event that answers "not
* enabled on this deployment" when a visitor runs it is a showcase that does not
* show (step-5 walk, W1). The seed switches on exactly these and nothing else.
*/
function eventActionIds(events = []) {
const ids = []
for (const e of events) {
for (const phase of e?.spec?.phases || []) {
for (const step of phase?.steps || []) {
if (step?.actionId && !ids.includes(step.actionId)) ids.push(step.actionId)
}
}
}
return ids
}
// ── The run ────────────────────────────────────────────────────────────────
/* eslint-disable global-require */
async function upsertAccounts(fixture) {
const users = require('../src/model/users/users.model')
const ids = {}
for (const username of DEMO_ACCOUNTS) {
const { password, role } = fixture.accounts[username]
const existing = await users.getRawByUsername(username)
let id
if (existing) {
id = existing.id
// A password change bumps the session cutoff, which signs every visitor out
// of this account — the right outcome at a wipe, and on a fresh database
// (the normal case) there is nobody to sign out.
await users.update(id, { password, role, status: 'active', emailVerified: true })
await users.disableTotp(id)
} else {
const created = await users.createUser({ username, password, role, status: 'active', emailVerified: true })
id = created.id
}
ids[username] = id
log.info(`account ${username} (${role}) ready`, { id })
}
return ids
}
async function writeSettings(fixture) {
const settings = require('../src/model/settings/settings.model')
await settings.setMany({
site_mode: 'live',
site_mode_changed_at: new Date().toISOString(),
site_mode_changed_by: 'seed:demo',
// §4: registration is closed, and the route is locked too (DM16).
[settings.REGISTRATION_KEY]: 'disabled',
...(fixture.settings || {}),
})
}
async function writeContent(fixture, authorId) {
const content = fixture.content || {}
const posts = require('../src/model/posts/posts.model')
const wiki = require('../src/model/wiki/wiki.model')
const pages = require('../src/model/pages/pages.model')
const events = require('../src/model/events/eventDefinitions.model')
for (const p of content.posts || []) {
const category = posts.toDbCategory(p.category || 'news')
if (!category) throw new Error(`post "${p.title}": unknown category "${p.category}"`)
await posts.create({ ...p, category, published: p.published !== false, author_id: authorId })
}
for (const w of content.wiki || []) {
const category = w.category ? await wiki.getCategoryBySlug(w.category) : null
if (w.category && !category) throw new Error(`wiki "${w.slug}": unknown category "${w.category}"`)
await wiki.create({ ...w, categoryId: category ? category.id : null, updatedBy: authorId })
}
for (const pg of content.pages || []) {
await pages.create(pg, authorId)
}
for (const e of content.events || []) {
const { publish, ...input } = e
const made = await events.create(input, authorId, { role: 'admin' })
if (!made.ok) throw new Error(`event "${input.name || input.title}": ${(made.errors || []).join('; ')}`)
if (publish) {
const pub = await events.publish(made.id, authorId)
if (!pub.ok) throw new Error(`event "${input.name || input.title}" did not publish: ${(pub.errors || []).join('; ')}`)
}
}
// Uncapped, like a switch an admin flips without filling the cap boxes: the
// deny list (DM11) is what keeps a visitor's own command step harmless, and the
// wipe takes the row away with the rest of the database.
const actionSettings = require('../src/model/events/eventActionSettings.db')
const enabled = eventActionIds(content.events)
for (const actionId of enabled) await actionSettings.put(actionId, { enabled: true, caps: {} }, authorId)
log.info('content written', {
posts: (content.posts || []).length,
wiki: (content.wiki || []).length,
pages: (content.pages || []).length,
events: (content.events || []).length,
actionsEnabled: enabled,
})
}
async function seedModules(fixture, accounts) {
const modules = require('../src/modules/loader')
const hooks = modules.demoSeedHooks()
const wanted = fixture.modules || {}
for (const id of Object.keys(wanted)) {
if (!hooks.some((h) => h.id === id)) {
throw new Error(`modules.${id}: no installed module "${id}" takes a demo seed (onDemoSeed)`)
}
}
for (const { id, hook } of hooks) {
if (wanted[id] === undefined) continue
// The module gets its part of the fixture and the demo accounts' ids, so it
// can link one of them to a game identity without reading core's users table.
await hook(wanted[id], { accounts: { ...accounts } })
log.info(`module ${id} seeded`)
}
}
async function writeState(fixture, now = new Date()) {
const settings = require('../src/model/settings/settings.model')
const everyHours = Number(process.env.DEMO_RESET_EVERY_HOURS) || 2
const logins = DEMO_ACCOUNTS
.filter((name) => fixture.accounts[name].published !== false)
.map((name) => ({ username: name, password: fixture.accounts[name].password, role: fixture.accounts[name].role }))
const state = { resetsAt: nextResetAt(now, everyHours).toISOString(), logins }
await settings.set(settings.DEMO_STATE_KEY, JSON.stringify(state))
log.info('demo state stamped', { resetsAt: state.resetsAt, logins: logins.length })
}
async function run(file) {
if (!isDemo()) throw new Error('seed:demo runs only with DEMO_MODE=true')
if (!file) throw new Error('usage: npm run seed:demo -- <fixture.json> (or set DEMO_SEED_FILE)')
const fixture = resolveEnv(JSON.parse(fs.readFileSync(path.resolve(file), 'utf8')))
const problems = checkFixture(fixture)
if (problems.length) throw new Error(`the fixture has problems:\n - ${problems.join('\n - ')}`)
// The app itself, without listening: this is what loads the installed modules
// (and core's registries, which an event definition is validated against)
// exactly as the server does. Nothing here calls onBoot, so no module starts
// its workers in this process.
require('../src/app')
const settings = require('../src/model/settings/settings.model')
const firstRun = !(await settings.get(settings.DEMO_STATE_KEY))
const accounts = await upsertAccounts(fixture)
await writeSettings(fixture)
if (firstRun) await writeContent(fixture, accounts['demo-admin'])
else log.info('content already seeded on this database; left as is')
await seedModules(fixture, accounts)
await writeState(fixture)
}
/* eslint-enable global-require */
if (require.main === module) {
;(async () => {
const { close } = require('../src/utils/db') // eslint-disable-line global-require
try {
await run(process.argv[2] || process.env.DEMO_SEED_FILE)
log.info('demo seeded')
} catch (err) {
log.error(`seed:demo failed: ${err.message}`)
process.exitCode = 1
} finally {
await close()
}
})()
}
module.exports = { resolveEnv, nextResetAt, checkFixture, eventActionIds, run }

View File

@@ -1,6 +1,6 @@
{
"_comment": "Generated event-trigger inventory - the authoritative freeze of CORE's engagement contract (docs/website/ENGAGEMENT.md 4.3). Regenerate with `npm run engagement:manifest` in website/server. A renamed variable, a changed type or a widened ceiling breaks stored templates and rules, so the diff here is the review signal. A module ships its own copy in its bundle; this file never contains one.",
"moduleApiVersion": "1.17.0",
"moduleApiVersion": "1.19.0",
"triggers": [
{
"id": "event.phase.changed",

View File

@@ -7,6 +7,7 @@
"start": "node src/server.js",
"dev": "nodemon src/server.js",
"seed": "node db/seed.js",
"seed:demo": "node db/seedDemo.js",
"swagger": "node swagger/swagger.js",
"routes:manifest": "node scripts/routeManifest.js",
"engagement:manifest": "node scripts/engagementManifest.js",

View File

@@ -21,6 +21,8 @@ const createLogger = require('./utils/logger')
const htmlShell = require('./utils/htmlShell')
const { applyTrustProxy, trustProxyDebug } = require('./utils/trustProxy')
const botScore = require('./middleware/botScore')
const { isDemo } = require('./demo/mode')
const demoLock = require('./demo/locks')
const httpLog = createLogger('http')
const errLog = createLogger('error')
@@ -84,6 +86,24 @@ app.use(morgan(accessFormat, { stream: { write: (line) => httpLog.info(line.trim
app.use(express.json({ limit: '2mb' }))
app.use(cookieParser())
// ── The public demo ───────────────────────────────────────────────────
// DEMO_MODE=true turns this deployment into demo.runicgateway.com
// (docs/website/DEMO_MODE.md). Unset, nothing here is mounted, so a real shard
// runs none of it. After the body parser, because one lock reads the body of
// PUT /admin/settings; after cookieParser, because the shared-login locks read
// the session; ahead of `/api` and of the SPA's static files (robots.txt).
// The module locks are asked per request: the loader runs further down.
if (isDemo()) {
app.use(demoLock.noindex)
app.get('/robots.txt', (req, res) => {
// #swagger.ignore = true
demoLock.robots(req, res)
})
app.use(demoLock.refuseMultipart)
app.use(demoLock.refuseDataUri)
app.use('/api/v1', demoLock.createLock(() => modules.demoLocks()))
}
// ── Paths ─────────────────────────────────────────────────────────────
const SERVER_ROOT = path.join(__dirname, '..')
const REPO_ROOT = path.join(SERVER_ROOT, '..')

246
server/src/demo/locks.js Normal file
View File

@@ -0,0 +1,246 @@
// ── The demo lock (docs/website/DEMO_MODE.md §3) ───────────────────────────
//
// With DEMO_MODE on, app.js mounts four middlewares from this file, and with it
// off none of them is mounted at all, so a real shard runs none of this code.
//
// • `refuseMultipart` — any request with a multipart body, anywhere, before a
// router sees it (§3.3 "Uploads — locked hard"). An upload route added later
// is locked without anyone remembering to list it, and a module cannot open one.
// • `lock` — each write request against core's list below plus every module's
// declared `demoLocks`, at `/api/v1`, ahead of every router.
// • `refuseDataUri` — any write whose JSON body carries a data: URI in any
// string (DM25): a pasted image is an upload that did not use multipart.
// • `noindex` + `robots` — nothing a visitor writes reaches a search engine.
//
// **The server is the lock (DM10).** Everything here is decided from the request
// alone, so `curl`, an old app build and a hand-edited page are refused exactly
// like a click. The client explains a refusal; it never enforces one.
//
// A refusal is always the same answer, so the web client and the app can each
// recognise it by `code` and show one sentence for it.
const { compile, match } = require('./paths')
const { isDemoUsername } = require('./mode')
const log = require('../utils/logger')('demo')
const CODE = 'DEMO_LOCKED'
const MESSAGE = 'This is turned off on the demo site.'
const WRITES = ['POST', 'PUT', 'PATCH', 'DELETE']
function refuse(res, extra = {}) {
return res.status(403).json({ message: MESSAGE, code: CODE, ...extra })
}
// ── Conditions ─────────────────────────────────────────────────────────────
// A few rows lock a route only for some requests. Each condition is asked only
// after the method and path have matched, so the DB is touched on a handful of
// rare admin and self-service writes and on nothing else.
// Required lazily: this file is required by app.js, and the users model would
// otherwise make the DB pool a startup dependency of the lock.
/* eslint-disable global-require */
const users = () => require('../model/users/users.model')
const sessions = () => require('../auth/session.service')
/* eslint-enable global-require */
async function isDemoUserId(id) {
const n = Number(id)
if (!Number.isInteger(n) || n < 1) return false
const user = await users().getById(n)
return Boolean(user && isDemoUsername(user.username))
}
// `PUT /admin/users/:id` and its kin: an admin acting on one of the shared logins.
const targetIsDemo = (req, params) => isDemoUserId(params.id)
// `/auth/me/*`: the caller signed in as one of the shared logins. The identity is
// read the way requireAuth reads it, and the account row decides — not the
// username claim in the token. No session is not a refusal: the route's own
// requireAuth answers that with its usual 401.
async function callerIsDemo(req) {
const session = sessions().validateSession(req)
return Boolean(session && (await isDemoUserId(session.userId)))
}
// `PUT /admin/settings` stays open (DM2), but that one route writes arbitrary
// keys, and a few keys are another door into something Tier A locks: `site_mode`
// is what `PUT /admin/site-mode` writes, `module_source_hosts` what the Modules
// screen's sources do, `teams_voice_*` the voice settings. `demo_*` is the
// banner and the published logins, which the seed owns. `hero_layout` is the
// live homepage hero (DM21): the editor's draft (`hero_layout_draft`) and its
// Preview stay open, its Publish is refused.
const PROTECTED_SETTINGS = [/^site_mode/, /^module_source_hosts$/, /^teams_voice_/, /^demo_/, /^hero_layout$/]
function protectedKeys(body) {
if (!body || typeof body !== 'object' || Array.isArray(body)) return []
return Object.keys(body).filter((key) => PROTECTED_SETTINGS.some((re) => re.test(key)))
}
// ── Core's list (§3.3) ─────────────────────────────────────────────────────
// Paths are relative to /api/v1 and written as the routers declare them;
// test/demoLocks.test.js holds every row against routes.manifest.json, so a
// row that stops naming a real route fails the build rather than reading as a lock.
const CORE_LOCKS = [
// Tier A — site configuration.
{ methods: ['PUT'], path: '/admin/site-mode' },
{ methods: WRITES, path: '/admin/modules/*' },
{ methods: WRITES, path: '/admin/auth/providers/*' },
{ methods: ['PUT'], path: '/admin/email/config' },
{ methods: ['POST'], path: '/admin/email/test' },
{ methods: ['POST'], path: '/admin/email/disconnect' },
{ methods: ['PUT'], path: '/admin/discord-bot/config' },
{ methods: WRITES, path: '/admin/teams/voice/*' },
{ methods: ['PUT'], path: '/admin/settings', when: (req) => protectedKeys(req.body).length > 0 },
// Tier A — the shared logins, by an admin.
{ methods: ['PUT', 'DELETE'], path: '/admin/users/:id', when: targetIsDemo },
{ methods: ['DELETE'], path: '/admin/users/:id/trusted-devices', when: targetIsDemo },
{ methods: ['DELETE'], path: '/admin/users/:id/trusted-devices/:deviceId', when: targetIsDemo },
{ methods: ['POST'], path: '/admin/users/:id/mfa/reset', when: targetIsDemo },
// Tier A — the shared logins, by whoever is signed in as one.
{ methods: ['PATCH'], path: '/auth/me/account/username', when: callerIsDemo },
{ methods: ['PATCH'], path: '/auth/me/account/password', when: callerIsDemo },
{ methods: ['PATCH'], path: '/auth/me/account/email', when: callerIsDemo },
{ methods: ['POST'], path: '/auth/me/account/totp/setup', when: callerIsDemo },
{ methods: ['POST'], path: '/auth/me/account/totp/enable', when: callerIsDemo },
{ methods: ['POST'], path: '/auth/me/account/totp/disable', when: callerIsDemo },
{ methods: ['POST'], path: '/auth/me/account/recovery-codes/generate', when: callerIsDemo },
{ methods: ['DELETE'], path: '/auth/me/account/identities/:provider', when: callerIsDemo },
// DM17: the whole route, because a mobile access token carries no refresh-row
// id and "a session that is not the caller's" cannot be decided. Signing out
// (POST /auth/mobile/logout) revokes the caller's own row and stays open.
{ methods: ['DELETE'], path: '/auth/me/sessions/:id', when: callerIsDemo },
// Tier A — credentials for everyone.
{ methods: ['POST'], path: '/auth/password/forgot' },
{ methods: ['POST'], path: '/auth/password/reset/:token' },
// DM16: settings are open, so `player_registration` can be switched on; the
// route is what keeps registration closed (§4).
{ methods: ['POST'], path: '/auth/register' },
// Tier C's one exception: the contact form mails a person.
{ methods: ['POST'], path: '/public/contact' },
// Uploads. `refuseMultipart` already stops every one of these; they are
// listed too so the table in §3.3 and this file say the same thing, and so a
// non-multipart body sent to an upload route is refused the same way.
{ methods: ['POST'], path: '/admin/uploads' },
{ methods: ['POST'], path: '/admin/posts/upload' },
{ methods: ['POST'], path: '/admin/settings/brand-asset/:slot' },
{ methods: ['POST'], path: '/player/teams/:slug/forum/uploads' },
].map((rule) => ({ ...rule, source: 'core', compiled: compile(rule.path) }))
/**
* Build the lock middleware.
*
* @param {() => object[]} moduleLocks returns the installed modules' compiled
* locks — asked per request, because the loader runs after app.js mounts this.
*/
function createLock(moduleLocks = () => []) {
return async function lock(req, res, next) {
if (!WRITES.includes(req.method)) return next()
const rules = CORE_LOCKS.concat(moduleLocks())
for (const rule of rules) {
if (!rule.methods.includes(req.method)) continue
const params = match(rule.compiled, req.path)
if (!params) continue
if (rule.when) {
let applies
try {
applies = await rule.when(req, params)
} catch (err) {
// Fail closed: a lock that cannot decide is a lock.
log.warn('lock condition failed; refusing', { path: rule.path, message: err.message })
applies = true
}
if (!applies) continue
}
log.info('refused', { method: req.method, path: req.originalUrl, rule: rule.path, source: rule.source })
const keys = rule.path === '/admin/settings' ? { keys: protectedKeys(req.body) } : {}
return refuse(res, keys)
}
return next()
}
}
// Any multipart content type, not only form-data: nothing on the demo takes one,
// and a narrower test is one more spelling to get wrong. The header alone
// decides, so a multipart request with no body is refused too.
function refuseMultipart(req, res, next) {
const type = String(req.headers['content-type'] || '').trim().toLowerCase()
if (!type.startsWith('multipart/')) return next()
log.info('refused upload', { method: req.method, path: req.originalUrl })
return refuse(res)
}
// DM25: an image pasted into rich text travels inside the JSON body as a data:
// URI, which is an upload by another name. Any string anywhere in a write's
// body is checked, so a field added later, a setting or a module's route is
// covered without being listed, as with multipart.
//
// A data: URI is `data:` (not the tail of a word like "metadata:") followed by
// a comma, a `;`, or a type/subtype and then a comma. Prose such as
// "Data: none, yet" does not match. Before testing, HTML character references
// are decoded and tabs and newlines removed, because a browser does both when
// it reads an attribute: `&#100;ata:` and `da<newline>ta:` are still data: URIs.
const DATA_URI = /(?<![a-z0-9+.-])data:\s*(?:[,;]|[a-z]+\/[a-z0-9.+-]+[^,<>"']{0,256},)/i
function browserReading(s) {
return s
.replace(/&#x([0-9a-f]+);?/gi, (m, hex) => safeChar(parseInt(hex, 16), m))
.replace(/&#(\d+);?/g, (m, dec) => safeChar(parseInt(dec, 10), m))
.replace(/&colon;/gi, ':')
.replace(/&(?:tab|newline);/gi, '')
.replace(/[\t\n\r]/g, '')
}
function safeChar(code, original) {
return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : original
}
function hasDataUri(body) {
const stack = [body]
while (stack.length) {
const value = stack.pop()
if (typeof value === 'string') {
if (value.length > 5 && DATA_URI.test(browserReading(value))) return true
} else if (value && typeof value === 'object') {
for (const v of Object.values(value)) stack.push(v)
}
}
return false
}
function refuseDataUri(req, res, next) {
if (!WRITES.includes(req.method) || !hasDataUri(req.body)) return next()
log.info('refused data: URI', { method: req.method, path: req.originalUrl })
return refuse(res)
}
function noindex(req, res, next) {
res.set('X-Robots-Tag', 'noindex, nofollow')
next()
}
function robots(req, res) {
res.type('text/plain').send('User-agent: *\nDisallow: /\n')
}
module.exports = {
CODE,
MESSAGE,
WRITES,
CORE_LOCKS,
PROTECTED_SETTINGS,
createLock,
refuseMultipart,
refuseDataUri,
hasDataUri,
noindex,
robots,
refuse,
protectedKeys,
}

26
server/src/demo/mode.js Normal file
View File

@@ -0,0 +1,26 @@
// ── Demo mode: the flag and the four shared accounts ───────────────────────
//
// `DEMO_MODE=true` on a deployment turns it into the public demo
// (docs/website/DEMO_MODE.md). Read per call rather than once at require time,
// so a test can flip it without re-requiring every file that asks — and so
// there is exactly one spelling of "is this the demo" in the codebase.
//
// Only the literal string `true` turns it on. A stray `DEMO_MODE=1` or `yes` on
// a real shard must not lock its admin panel, and the opposite mistake — a demo
// that forgot the flag — is caught by `seed:demo`, which refuses to run without it.
function isDemo() {
return process.env.DEMO_MODE === 'true'
}
// The static logins (DEMO_MODE.md §4). Fixed here, in core, rather than read
// from the seed fixture: the lock has to recognise them on every request, and a
// fixture that dropped one would quietly unlock the account everybody shares.
// The seed creates exactly these; their passwords come from the fixture.
const DEMO_ACCOUNTS = Object.freeze(['demo-admin', 'demo-moderator', 'demo-player', 'play-review'])
function isDemoUsername(username) {
return typeof username === 'string' && DEMO_ACCOUNTS.includes(username.toLowerCase())
}
module.exports = { isDemo, DEMO_ACCOUNTS, isDemoUsername }

70
server/src/demo/paths.js Normal file
View File

@@ -0,0 +1,70 @@
// ── Lock patterns: `:param` segments and a trailing `*` ────────────────────
//
// The demo's lock list names routes the way the routers declare them —
// `/admin/users/:id` — so a reviewer can hold the list against the route
// manifest line by line. A trailing `/*` means "this path and everything under
// it", for the areas DEMO_MODE.md §3.3 locks as a whole ("every write under
// /admin/modules").
//
// The matcher reproduces the two express 4 routing defaults that decide whether
// a request reaches a handler at all: matching is case-insensitive, and a
// trailing slash is optional. A lock that was stricter than the router about
// either would let `PUT /Admin/Site-Mode/` through to a handler that answers it.
const SEGMENT = /^(?::[A-Za-z][A-Za-z0-9]*|[A-Za-z0-9._-]+)$/
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
/**
* Compile a lock pattern.
*
* @param {string} pattern e.g. `/admin/users/:id` or `/admin/modules/*`
* @returns {{pattern: string, names: string[], re: RegExp}}
* @throws on a pattern that is not absolute, has an empty segment, or puts `*`
* anywhere but last — a lock that silently compiled to something else would
* be worse than no lock, because it would read as one.
*/
function compile(pattern) {
if (typeof pattern !== 'string' || !pattern.startsWith('/')) {
throw new Error(`lock path "${pattern}" must start with /`)
}
const segs = pattern.slice(1).split('/')
const names = []
let source = ''
segs.forEach((seg, i) => {
if (seg === '*') {
if (i !== segs.length - 1) throw new Error(`lock path "${pattern}": * must be the last segment`)
source += '(?:/.*)?'
return
}
if (!SEGMENT.test(seg)) throw new Error(`lock path "${pattern}": bad segment "${seg}"`)
if (seg.startsWith(':')) {
names.push(seg.slice(1))
source += '/([^/]+)'
} else {
source += `/${escape(seg)}`
}
})
return { pattern, names, re: new RegExp(`^${source}/?$`, 'i') }
}
/**
* Match a request path against a compiled pattern.
*
* @returns {Record<string,string>|null} the decoded params, or null
*/
function match(compiled, path) {
const m = compiled.re.exec(path)
if (!m) return null
const params = {}
compiled.names.forEach((name, i) => {
try {
params[name] = decodeURIComponent(m[i + 1])
} catch {
params[name] = m[i + 1]
}
})
return params
}
module.exports = { compile, match }

View File

@@ -29,6 +29,8 @@ const spec = require('../../events/spec')
const authorize = require('../../events/authorize')
const verifier = require('../../events/verify')
const registries = require('../../modules/registries')
const { isDemo } = require('../../demo/mode')
const { CODE: DEMO_LOCKED } = require('../../demo/locks')
const { slugify, uniqueSlug } = require('../teams/teamSlug')
const { cleanBody } = require('../../utils/sanitizeHtml')
const log = require('../../utils/logger')('events')
@@ -181,6 +183,8 @@ async function create(input, userId, { role = null } = {}) {
if (!result.ok) return result
const floor = checkRoleFloor(result.definition.spec, role)
if (floor) return floor
const refused = await checkDemoRefusals(result.definition.spec)
if (refused) return refused
const id = await db.insert({ ...result.definition, created_by: userId })
return { ok: true, id, definition: await db.getById(id) }
}
@@ -203,6 +207,8 @@ async function save(id, input, userId, { role = null } = {}) {
if (!result.ok) return result
const floor = checkRoleFloor(result.definition.spec, role)
if (floor) return floor
const refused = await checkDemoRefusals(result.definition.spec)
if (refused) return refused
await db.update(id, { ...result.definition, updated_by: userId })
return { ok: true, id, definition: await db.getById(id) }
}
@@ -228,6 +234,11 @@ async function publish(id, userId) {
})
if (!checked.ok) return { ok: false, status: 400, errors: checked.errors }
// Asked again here, as the registries are: a draft saved before the module's
// list grew must not become a version that runs (DM18).
const refused = await checkDemoRefusals(checked.spec)
if (refused) return refused
const publishable = spec.publishable(checked.spec)
if (!publishable.ok) {
return {
@@ -365,6 +376,37 @@ function checkRoleFloor(specValue, role) {
}
}
/**
* The public demo's save-time refusal (MODULE_API 1.19.0, DEMO_MODE.md §3.4,
* DM18): each step whose action declares `demoRefuses(params)` is asked, and
* the first sentence back refuses the whole save `403 DEMO_LOCKED`. Null off
* the demo, where nothing is asked.
*
* A hook that throws refuses too, with core's sentence: this is a lock, and a
* lock that cannot decide fails closed, as the route lock does (§3.3).
*/
async function checkDemoRefusals(specValue) {
if (!isDemo()) return null
for (const phase of specValue?.phases || []) {
for (const step of phase.steps || []) {
const action = registries.eventAction(step.actionId)
if (!action || !action.demoRefuses) continue
let said
try {
said = await action.demoRefuses(step.params || {})
} catch (err) {
log.error('demoRefuses failed', { action: action.id, message: err.message })
said = `"${action.label}" could not be checked, so it is turned off on the demo site.`
}
if (typeof said === 'string' && said.trim()) {
const message = said.trim()
return { ok: false, status: 403, code: DEMO_LOCKED, message, errors: [message] }
}
}
}
return null
}
/**
* Dry-run a definition, and record the pass when there is a version to record it
* on (Phase 6).
@@ -545,6 +587,7 @@ module.exports = {
archive,
verify,
checkRoleFloor,
checkDemoRefusals,
isTimezone,
MIN_GRACE_SECONDS,
MAX_GRACE_SECONDS,

View File

@@ -3,6 +3,7 @@ const brand = require('../../config/brand')
const { parseJsonSetting } = require('../../utils/settingsJson')
const { resolveThemeTokens } = require('../../utils/themeResolve')
const { resolveBrandAssets } = require('../../utils/brandAssets')
const { isDemo } = require('../../demo/mode')
// Keys safe to expose on the public site.
const PUBLIC_KEYS = [
@@ -215,9 +216,30 @@ async function getPublic() {
// before doing so (the server would otherwise reject an unallowlisted one). The
// custom-scheme callback works regardless of this flag.
out.mobileAppLinks = String(all[MOBILE_APP_LINKS_KEY]) === 'true'
// The public demo's banner and sign-in panel (DEMO_MODE.md §4, §6). Present
// only with DEMO_MODE on, so a real shard's payload is unchanged. Written by
// `seed:demo` at every wipe; the demo lock keeps `demo_*` out of PUT
// /admin/settings, so a visitor cannot rewrite the published passwords.
if (isDemo()) out.demo = demoBlock(all[DEMO_STATE_KEY])
return out
}
// `demo_state` is one JSON object: `{ resetsAt, logins: [{ username, password,
// role }] }`. The passwords are public by design (§4) — the logins are printed
// on the sign-in page and on runicgateway.com/beta. Read forgivingly: a missing
// or broken row is a banner with no time and no logins, never a 500.
const DEMO_STATE_KEY = 'demo_state'
function demoBlock(raw) {
const state = parseJsonSetting(raw) || {}
const resetsAt = typeof state.resetsAt === 'string' && !Number.isNaN(Date.parse(state.resetsAt))
? state.resetsAt
: null
const logins = (Array.isArray(state.logins) ? state.logins : [])
.filter((l) => l && typeof l.username === 'string' && typeof l.password === 'string')
.map((l) => ({ username: l.username, password: l.password, role: typeof l.role === 'string' ? l.role : null }))
return { resetsAt, logins }
}
/**
* What the HTML shell needs, resolved exactly as getPublic() resolves it: the
* effective favicon and logo, plus the theme token map for the boot <style>
@@ -285,4 +307,5 @@ module.exports = {
registrationFlags,
MOBILE_APP_LINKS_KEY,
isMobileAppLinksEnabled,
DEMO_STATE_KEY,
}

View File

@@ -135,7 +135,7 @@ async function syncStatus() {
configured: true,
lastSyncAt,
// Never synced at all is stale: a page must not present an empty projection
// as a confirmed empty shard.
// as a confirmed empty one.
stale: ageS > intervalS * STALE_INTERVALS,
consecutiveFailures: state ? state.consecutive_failures : 0,
}

View File

@@ -41,6 +41,8 @@ const { MODULE_API_VERSION } = require('./version')
const semver = require('./semver')
const registries = require('./registries')
const { splitStatements } = require('../utils/sqlStatements')
const demoPaths = require('../demo/paths')
const { isDemo } = require('../demo/mode')
const log = require('../utils/logger')('modules')
@@ -62,9 +64,15 @@ const TIERS = ['public', 'admin', 'player']
const MANIFEST_KEYS = new Set([
'id', 'name', 'version', 'coreApi', 'server', 'client',
'schema', 'purge', 'mounts', 'extensions', 'capabilities',
'schema', 'purge', 'mounts', 'extensions', 'capabilities', 'demoLocks',
])
// What a `demoLocks` entry may lock (API 1.18.0, DEMO_MODE.md §3.2): writes
// only. Reads are never locked by declaration — the one read the demo refuses
// (a plugin config that holds credentials) depends on WHICH file is asked for,
// so the module decides it behind `ctx.demoMode` rather than by route.
const DEMO_LOCK_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE']
// Extension slots are declared by core, at require time, in the router that owns
// the resource (registries.declareSlot). The loader asks the registry which exist
// rather than keeping a list, for the same reason the prefix check probes the
@@ -338,6 +346,12 @@ function buildCtx(id, moduleRoot) {
// configuration, so core answers it. A getter, not a captured string: the
// value is read per call, so it cannot go stale against the env.
site: { get baseUrl() { return (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '') } },
// API 1.18.0 (DEMO_MODE.md §3.4, §3.5). True on the public demo. For the
// locks a route list cannot express — a console command by its first word,
// a config file by which plugin owns it — which the module enforces in its
// own handlers. A boolean, read once: the flag is the deployment's, and a
// deployment does not change what it is while it runs.
demoMode: isDemo(),
}
// A guard against accident, not against a hostile module — the boundary is
// organisational, not a security boundary (MODULE_SYSTEM.md §2.2).
@@ -503,6 +517,12 @@ function buildApi(record) {
// nothing to undo when a sign-in identity goes.
onIdentityLinking: hook('onIdentityLinking'),
onIdentityLinked: hook('onIdentityLinked'),
// API 1.18.0 (DEMO_MODE.md §7.3, DM15). Core's `seed:demo` handing the
// module its part of the seed fixture, so the module writes its own rows —
// a game server link, a player's game link — through its own model. Core
// never writes a module's tables. Called from the seed script only, never
// by the running server, and only with DEMO_MODE on.
onDemoSeed: hook('onDemoSeed'),
}
}
@@ -741,6 +761,8 @@ function readManifest(dir, id, tierRouters) {
if (!registries.hasSlot(slot)) fail('extensions', `unknown extension slot "${slot}"`)
}
checkDemoLocks(manifest)
if (manifest.client !== undefined) {
if (typeof manifest.client !== 'object' || manifest.client === null || Array.isArray(manifest.client)) {
fail('manifest', 'client must be an object')
@@ -761,6 +783,40 @@ function readManifest(dir, id, tierRouters) {
return manifest
}
/**
* Validate `demoLocks` (API 1.18.0, DEMO_MODE.md §3.2), on every boot and not
* only on the demo: a module whose declaration is wrong should find out on the
* developer's machine, not on the one deployment where it is read.
*
* Each entry is `{ method, path }`, the path relative to /api/v1 and under one
* of the module's own declared mounts — a module may lock its own routes and
* nobody else's, the same boundary `mounts` draws for serving them.
*/
function checkDemoLocks(manifest) {
if (manifest.demoLocks === undefined) return
if (!Array.isArray(manifest.demoLocks)) fail('manifest', 'demoLocks must be an array')
for (const entry of manifest.demoLocks) {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
fail('manifest', 'each demoLocks entry must be an object')
}
for (const key of Object.keys(entry)) {
if (key !== 'method' && key !== 'path') fail('manifest', `unknown key "demoLocks.${key}" in module.json`)
}
if (!DEMO_LOCK_METHODS.includes(entry.method)) {
fail('manifest', `demoLocks method "${entry.method}" must be one of ${DEMO_LOCK_METHODS.join(', ')}`)
}
try {
demoPaths.compile(entry.path)
} catch (err) {
fail('manifest', `demoLocks: ${err.message}`)
}
const [, tier, prefix] = entry.path.split('/')
if (!(manifest.mounts?.[tier] || []).includes(`/${prefix}`)) {
fail('mounts', `demoLocks path ${entry.path} is outside the module's mounts`)
}
}
}
// What the module registered must equal what it declared — in both directions.
function checkDeclared(record) {
const declared = record.manifest.mounts || {}
@@ -841,7 +897,9 @@ function load(tierRouters) {
staged: registries.stage(id),
tables: new Set(),
called: new Set(),
hooks: { onBoot: null, onShutdown: null, onIdentityLinking: null, onIdentityLinked: null },
hooks: {
onBoot: null, onShutdown: null, onIdentityLinking: null, onIdentityLinked: null, onDemoSeed: null,
},
client: null,
ctx: null,
state: 'installed',
@@ -1128,6 +1186,55 @@ function identityHooks(name) {
.map((r) => ({ id: r.id, hook: r.hooks[name] }))
}
/**
* Every installed module's `demoLocks`, compiled, for the demo lock in
* demo/locks.js (API 1.18.0).
*
* Every module whose manifest validated, whatever its state. A disabled or
* failed module's routes already answer 404 or 503, so its locks change
* nothing — and a lock that disappeared whenever its module was between states
* is a lock with a gap in it. Empty before load() has run, rather than throwing:
* the middleware is mounted before the loader and asks per request.
*
* @returns {{methods: string[], path: string, compiled: object, source: string}[]}
*/
let demoLockCache = null
function demoLocks() {
if (!loaded) return []
// Manifests are read once, in load(), which is synchronous — so the answer
// cannot change after the first request asks, and is compiled once.
if (demoLockCache) return demoLockCache
const out = []
for (const record of modules.values()) {
for (const entry of record.manifest?.demoLocks || []) {
out.push({
methods: [entry.method],
path: entry.path,
compiled: demoPaths.compile(entry.path),
source: record.id,
})
}
}
demoLockCache = out
return out
}
/**
* The `onDemoSeed` hooks, for `npm run seed:demo` (API 1.18.0, DM15).
*
* `registered` or `started`: the seed is its own process, where nothing calls
* onBoot, so its modules never get past `registered`. A module that failed to
* load is not handed a fixture for tables it may not have.
*
* @returns {{id: string, hook: Function, ctx: object}[]}
*/
function demoSeedHooks() {
assertLoaded('demoSeedHooks')
return [...modules.values()]
.filter((r) => (r.state === 'registered' || r.state === 'started') && r.hooks.onDemoSeed)
.map((r) => ({ id: r.id, hook: r.hooks.onDemoSeed, ctx: r.ctx }))
}
/**
* One module's `onShutdown`, for stopping it on its own rather than at exit.
*
@@ -1254,6 +1361,8 @@ module.exports = {
bootable,
shutdownHooks,
identityHooks,
demoLocks,
demoSeedHooks,
stopHook,
clientChunks,
clientEntryUrls,

View File

@@ -445,14 +445,14 @@ async function resolveAudience(id, params = {}) {
/**
* Every declaration WITHOUT its callables — what the admin catalog serves.
*
* `perform`, `revert`, `reconcile`, `cost` and `progress` are stripped for the same reason `resolve` is
* `perform`, `revert`, `reconcile`, `cost`, `progress` and `demoRefuses` are stripped for the same reason `resolve` is
* stripped from an audience and `handler` from a slash command: this is the
* object that leaves the process, and the browser's whole relationship with an
* action is naming one by id. §F's "a module registers actions server-side and
* adds no routes for them" is only true if the functions never ride out.
*/
const allEventActions = () =>
[...eventActions.values()].map(({ perform, revert, reconcile, cost, progress, ...rest }) => rest)
[...eventActions.values()].map(({ perform, revert, reconcile, cost, progress, demoRefuses, ...rest }) => rest)
/** One declaration, callables included. The runner's lookup (Phase 2). */
const eventAction = (id) => eventActions.get(id) || null
@@ -1128,7 +1128,7 @@ function checkActionParam(actionId, entry, seen, call = 'registerEventActions')
}
/**
* `registerEventActions([{ id, label, risk, reversible, version, budgetMs, cost, params, perform, revert, reconcile, progress }])`.
* `registerEventActions([{ id, label, risk, reversible, version, budgetMs, cost, params, perform, revert, reconcile, progress, demoRefuses }])`.
*
* A typed verb core may ask a registrant to carry out. Everything decidable from
* the argument alone is decided here, at the call; the collision — is this id
@@ -1211,6 +1211,13 @@ function checkEventActionShape(entry) {
if (a.progress !== undefined && typeof a.progress !== 'function') {
throw new Error(`registerEventActions: ${a.id} progress must be a function`)
}
// 1.19.0, optional: the public demo's save-time refusal (DEMO_MODE.md §3.4,
// DM18). Asked only with DEMO_MODE on, by the definition save path; a
// sentence back refuses the save `403 DEMO_LOCKED`. The module still checks
// at run, because a definition can predate the list it is checked against.
if (a.demoRefuses !== undefined && typeof a.demoRefuses !== 'function') {
throw new Error(`registerEventActions: ${a.id} demoRefuses must be a function of its params`)
}
const version = a.version === undefined ? 1 : a.version
if (!Number.isInteger(version) || version < 1) {
@@ -1244,6 +1251,7 @@ function checkEventActionShape(entry) {
revert: a.revert || null,
reconcile: a.reconcile || null,
progress: a.progress || null,
demoRefuses: a.demoRefuses || null,
}
}

View File

@@ -9,6 +9,29 @@
// Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and
// has nothing to say about a website module) and from any module's own version.
// 1.19.0 — the public demo refuses a step at SAVE (docs/website/DEMO_MODE.md
// §3.4, DM18). An event action may declare `demoRefuses(params)`, returning a
// sentence or null. With DEMO_MODE on, core asks it for every step when a
// definition is created, saved or published, and a sentence refuses the whole
// write `403 DEMO_LOCKED` with that sentence as `message`; a hook that throws
// refuses too. Off the demo it is validated and never called. One optional
// action member, so minor. Module-uo declares `^1.10.0` and declares none.
// Module-Rust's console-command deny list is the first user.
//
// 1.18.0 — the public demo (docs/website/DEMO_MODE.md §3.2, §3.4, §7.3; website#222).
// Three additions, each optional:
// • `demoLocks` in module.json — `[{ method, path }]`, writes only, each path
// under one of the module's own mounts. With DEMO_MODE on, core answers a
// matching request `403 DEMO_LOCKED` before the module's handler runs; with
// it off the list is validated and otherwise unread;
// • `ctx.demoMode` — true on the demo, for the locks a route cannot express,
// which the module enforces in its own handlers;
// • `api.onDemoSeed(fn)` — `npm run seed:demo` hands the module its part of
// the seed fixture (DM15), so it writes its own rows.
// Additions only, so minor. Module-uo declares `^1.10.0` and uses none of them.
// A module that DECLARES `demoLocks` needs `^1.18.0`, because older cores refuse
// a manifest key they do not know.
//
// 1.17.0 — a module may take part in linking an external sign-in identity
// (docs/website/STEAM_SIGNIN.md §4; Rust PLAN_REDESIGNS §15, D392 and D396).
// Three additions, each optional:
@@ -231,6 +254,6 @@
// an admin action a module performs belongs in core's one audit log, the
// extension slot needs the user its prefix names, and §2.7 forbids a module
// reading core's `APP_BASE_URL` for itself. Additions only, so minor.
const MODULE_API_VERSION = '1.17.0'
const MODULE_API_VERSION = '1.19.0'
module.exports = { MODULE_API_VERSION }

View File

@@ -42,6 +42,16 @@ const asId = (raw) => {
return Number.isInteger(n) && n > 0 ? n : null
}
/**
* A refused save's body: the errors, plus `code` and `message` when the model
* set them. The demo's step refusal does (DM18), so the client's one
* `DEMO_LOCKED` toast fires as it does for a route lock.
*/
const refusal = (result) => ({
errors: result.errors,
...(result.code ? { code: result.code, message: result.message } : {}),
})
/**
* The shape a definition takes on the wire.
*
@@ -413,7 +423,7 @@ exports.listVersions = async (req, res) => {
/** POST /api/v1/admin/events */
exports.create = async (req, res) => {
const result = await definitions.create(req.body, req.user.id, { role: req.user.role })
if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors })
if (!result.ok) return res.status(result.status || 400).json(refusal(result))
await activity.log({
req,
action: 'event.definition.created',
@@ -427,7 +437,7 @@ exports.update = async (req, res) => {
const id = asId(req.params.id)
if (!id) return res.status(400).json({ error: 'bad event id' })
const result = await definitions.save(id, req.body, req.user.id, { role: req.user.role })
if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors })
if (!result.ok) return res.status(result.status || 400).json(refusal(result))
await activity.log({
req,
action: 'event.definition.updated',
@@ -441,7 +451,7 @@ exports.publish = async (req, res) => {
const id = asId(req.params.id)
if (!id) return res.status(400).json({ error: 'bad event id' })
const result = await definitions.publish(id, req.user.id)
if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors })
if (!result.ok) return res.status(result.status || 400).json(refusal(result))
await activity.log({
req,
action: 'event.definition.published',

View File

@@ -3,7 +3,7 @@
"info": {
"title": "Runic Gateway API",
"version": "1.0.0",
"description": "REST API for the Runic Gateway website, wiki and admin panel.\n\nThis document is core. Installed modules add their own paths, tags and schemas to it at request time from the fragment each one ships, so `/api/docs.json` on a running instance describes more than `npm run swagger` generates here (docs/website/MODULE_API.md §6.1a).\n\n### Authentication\n- **Web / admin panel** uses an httpOnly session cookie (`rg_token`) issued by `POST /api/v1/auth/login` (plus `/login/totp` when 2FA is enabled).\n- **Native / mobile clients** use bearer access tokens from `POST /api/v1/auth/mobile/login`, refreshed via `/auth/mobile/refresh`.\n\nEndpoints under `/api/v1/admin/**` require a valid session; some are further restricted to the `admin` role (editors are limited to content)."
"description": "REST API for the Runic Gateway website, wiki and admin panel.\n\nThis document is core. Installed modules add their own paths, tags and schemas to it at request time from the fragment each one ships, so `/api/docs.json` on a running instance describes more than `npm run swagger` generates here (docs/website/MODULE_API.md §6.1a).\n\n### Authentication\n- **Web / admin panel** uses an httpOnly session cookie (`rg_token`) issued by `POST /api/v1/auth/login` (plus `/login/totp` when 2FA is enabled).\n- **Native / mobile clients** use bearer access tokens from `POST /api/v1/auth/mobile/login`, refreshed via `/auth/mobile/refresh`.\n\nEndpoints under `/api/v1/admin/**` require a valid session; some are further restricted to the `admin` role (editors are limited to content).\n\n### The public demo\nA deployment run with `DEMO_MODE=true` (the public demo, docs/website/DEMO_MODE.md) refuses a fixed set of write routes, and any `multipart/*` request, before the route runs. **Any write route may then answer** `403` with the `DemoLocked` body (`code: \"DEMO_LOCKED\"`); clients recognise the refusal by `code`, not by status. Reads are never refused this way. With the flag unset no route answers it."
},
"servers": [
{
@@ -18544,6 +18544,119 @@
}
}
},
"DemoLocked": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "The public demo refused a write (DEMO_MODE.md §3). Only on a deployment with `DEMO_MODE=true`. `keys` is present on `PUT /admin/settings` and names the settings the demo keeps. `errors` is present when an event save or publish is refused because a step's action refused its params (MODULE_API 1.19.0 `demoRefuses`, DM18); `message` is then the module's sentence."
},
"properties": {
"type": "object",
"properties": {
"message": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "This is turned off on the demo site."
}
}
},
"code": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"enum": {
"type": "array",
"example": [
"DEMO_LOCKED"
],
"items": {
"type": "string"
}
}
}
},
"keys": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "array"
},
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
}
}
},
"example": {
"type": "array",
"example": [
"site_mode"
],
"items": {
"type": "string"
}
}
}
},
"errors": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "array"
},
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
}
}
},
"example": {
"type": "array",
"example": [
"\"quit\" is turned off on the demo site."
],
"items": {
"type": "string"
}
}
}
}
}
},
"required": {
"type": "array",
"example": [
"message",
"code"
],
"items": {
"type": "string"
}
}
}
},
"ValidationError": {
"type": "object",
"properties": {
@@ -25600,6 +25713,111 @@
}
}
}
},
"demo": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "Present only on the public demo (`DEMO_MODE=true`, DEMO_MODE.md §4, §6). `resetsAt` is when the next wipe is due, for the banner; `logins` are the published static sign-ins — public by design, not secrets. Both are written by `npm run seed:demo` at every wipe."
},
"properties": {
"type": "object",
"properties": {
"resetsAt": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"format": {
"type": "string",
"example": "date-time"
},
"nullable": {
"type": "boolean",
"example": true
},
"example": {
"type": "string",
"example": "2026-10-24T14:00:00.000Z"
}
}
},
"logins": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "array"
},
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"properties": {
"type": "object",
"properties": {
"username": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "demo-admin"
}
}
},
"password": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "demo-admin"
}
}
},
"role": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"nullable": {
"type": "boolean",
"example": true
},
"example": {
"type": "string",
"example": "admin"
}
}
}
}
}
}
}
}
}
}
}
}
}
}
},

View File

@@ -44,7 +44,13 @@ const doc = {
'- **Native / mobile clients** use bearer access tokens from ' +
'`POST /api/v1/auth/mobile/login`, refreshed via `/auth/mobile/refresh`.\n\n' +
'Endpoints under `/api/v1/admin/**` require a valid session; some are further ' +
'restricted to the `admin` role (editors are limited to content).',
'restricted to the `admin` role (editors are limited to content).\n\n' +
'### The public demo\n' +
'A deployment run with `DEMO_MODE=true` (the public demo, docs/website/DEMO_MODE.md) ' +
'refuses a fixed set of write routes, and any `multipart/*` request, before the ' +
'route runs. **Any write route may then answer** `403` with the `DemoLocked` body ' +
'(`code: "DEMO_LOCKED"`); clients recognise the refusal by `code`, not by status. ' +
'Reads are never refused this way. With the flag unset no route answers it.',
},
servers: [
{ url: '/', description: 'Same-origin (current host)' },
@@ -100,6 +106,18 @@ const doc = {
type: 'object',
properties: { message: { type: 'string', example: 'Not found' } },
},
DemoLocked: {
type: 'object',
description:
'The public demo refused a write (DEMO_MODE.md §3). Only on a deployment with `DEMO_MODE=true`. `keys` is present on `PUT /admin/settings` and names the settings the demo keeps. `errors` is present when an event save or publish is refused because a step\'s action refused its params (MODULE_API 1.19.0 `demoRefuses`, DM18); `message` is then the module\'s sentence.',
properties: {
message: { type: 'string', example: 'This is turned off on the demo site.' },
code: { type: 'string', enum: ['DEMO_LOCKED'] },
keys: { type: 'array', items: { type: 'string' }, example: ['site_mode'] },
errors: { type: 'array', items: { type: 'string' }, example: ['"quit" is turned off on the demo site.'] },
},
required: ['message', 'code'],
},
ValidationError: {
type: 'object',
properties: {
@@ -1080,6 +1098,25 @@ const doc = {
ntfyUrl: { type: 'string', nullable: true, example: 'https://ntfy.example.com' },
},
},
demo: {
type: 'object',
description:
'Present only on the public demo (`DEMO_MODE=true`, DEMO_MODE.md §4, §6). `resetsAt` is when the next wipe is due, for the banner; `logins` are the published static sign-ins — public by design, not secrets. Both are written by `npm run seed:demo` at every wipe.',
properties: {
resetsAt: { type: 'string', format: 'date-time', nullable: true, example: '2026-10-24T14:00:00.000Z' },
logins: {
type: 'array',
items: {
type: 'object',
properties: {
username: { type: 'string', example: 'demo-admin' },
password: { type: 'string', example: 'demo-admin' },
role: { type: 'string', nullable: true, example: 'admin' },
},
},
},
},
},
},
additionalProperties: true,
},

View File

@@ -0,0 +1,86 @@
// ── The demo lock, wired into the real app (DEMO_MODE.md §3.1) ─────────────
//
// demoLocks.test.js proves each rule against a stand-in app. This file proves
// the wiring: with DEMO_MODE=true, app.js mounts the lock ahead of every
// router, so a locked route is refused before its handler — before even its
// auth check — and an open one still reaches it. One process per flag value,
// since app.js decides at require time: this file is the demo, and every other
// test file that requires app.js is the normal deployment.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
process.env.DEMO_MODE = 'true'
process.env.MODULES_DIR = require('path').join(require('os').tmpdir(), 'rg-demo-app-no-modules')
const { test, after } = require('node:test')
const assert = require('node:assert/strict')
const app = require('../src/app')
const db = require('../src/utils/db')
after(() => db.close())
async function withServer(fn) {
const server = await new Promise((resolve) => {
const s = app.listen(0, '127.0.0.1', () => resolve(s))
})
try {
return await fn(`http://127.0.0.1:${server.address().port}`)
} finally {
server.closeAllConnections()
await new Promise((resolve) => server.close(resolve))
}
}
test('a locked route answers DEMO_LOCKED before its own auth check', async () => {
await withServer(async (base) => {
const res = await fetch(`${base}/api/v1/admin/site-mode`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: 'maintenance' }),
})
assert.equal(res.status, 403)
assert.equal((await res.json()).code, 'DEMO_LOCKED')
})
})
test('an open route still reaches its handler, which answers as usual', async () => {
await withServer(async (base) => {
const res = await fetch(`${base}/api/v1/admin/posts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'x' }),
})
assert.equal(res.status, 401) // requireAuth, not the lock
})
})
test('an upload is refused before multer or auth sees it', async () => {
await withServer(async (base) => {
const form = new FormData()
form.append('image', new Blob(['x'], { type: 'image/png' }), 'x.png')
const res = await fetch(`${base}/api/v1/admin/uploads`, { method: 'POST', body: form })
assert.equal(res.status, 403)
assert.equal((await res.json()).code, 'DEMO_LOCKED')
})
})
test('a pasted data: image is refused before auth sees it, on an open route', async () => {
await withServer(async (base) => {
const res = await fetch(`${base}/api/v1/admin/posts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'x', body: '<p><img src="data:image/png;base64,iVBORw0KGgo="></p>' }),
})
assert.equal(res.status, 403)
assert.equal((await res.json()).code, 'DEMO_LOCKED')
})
})
test('every response carries noindex, and robots.txt disallows everything', async () => {
await withServer(async (base) => {
const health = await fetch(`${base}/api/health`)
assert.equal(health.headers.get('x-robots-tag'), 'noindex, nofollow')
const robots = await fetch(`${base}/robots.txt`)
assert.equal(await robots.text(), 'User-agent: *\nDisallow: /\n')
})
})

View File

@@ -0,0 +1,37 @@
// ── Without DEMO_MODE, none of the demo is mounted (DEMO_MODE.md §2) ───────
//
// The other half of demoApp.test.js, in its own process because app.js reads
// the flag at require time. A normal shard must answer exactly as it did before
// demo mode existed: its own auth check, no noindex header, its own robots.txt.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
delete process.env.DEMO_MODE
process.env.MODULES_DIR = require('path').join(require('os').tmpdir(), 'rg-demo-app-no-modules')
const { test, after } = require('node:test')
const assert = require('node:assert/strict')
const app = require('../src/app')
const db = require('../src/utils/db')
after(() => db.close())
test('a route the demo locks answers its own 401 on a normal deployment', async () => {
const server = await new Promise((resolve) => {
const s = app.listen(0, '127.0.0.1', () => resolve(s))
})
try {
const base = `http://127.0.0.1:${server.address().port}`
const res = await fetch(`${base}/api/v1/admin/site-mode`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: 'maintenance' }),
})
assert.equal(res.status, 401)
const health = await fetch(`${base}/api/health`)
assert.equal(health.headers.get('x-robots-tag'), null)
} finally {
server.closeAllConnections()
await new Promise((resolve) => server.close(resolve))
}
})

View File

@@ -0,0 +1,378 @@
// ── The demo lock (docs/website/DEMO_MODE.md §3) ───────────────────────────
//
// Every row of §3.3 that core owns, the matcher's two express defaults, the
// conditions on the shared-login rows, the settings keys that are another door
// into a Tier A lock, the multipart rule, and the module locks. The whole-app
// wiring — mounted with DEMO_MODE on, absent with it off — is demoApp.test.js.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, afterEach, after } = require('node:test')
const assert = require('node:assert/strict')
const db = require('../src/utils/db')
const users = require('../src/model/users/users.model')
const sessionService = require('../src/auth/session.service')
const { compile, match } = require('../src/demo/paths')
const locks = require('../src/demo/locks')
const { isDemo, isDemoUsername, DEMO_ACCOUNTS } = require('../src/demo/mode')
const manifest = require('../routes.manifest.json')
const { startApp } = require('./_helper')
after(() => db.close())
const originalGetById = users.getById
const originalValidate = sessionService.validateSession
afterEach(() => {
users.getById = originalGetById
sessionService.validateSession = originalValidate
delete process.env.DEMO_MODE
})
// id 1-4 are the shared logins, 9 is somebody an admin created on the demo.
const ACCOUNTS = { 1: 'demo-admin', 2: 'demo-moderator', 3: 'demo-player', 4: 'play-review', 9: 'alice' }
function stubUsers() {
users.getById = async (id) => (ACCOUNTS[id] ? { id, username: ACCOUNTS[id], role: 'admin' } : null)
}
function signedInAs(userId) {
sessionService.validateSession = () => (userId ? { userId, username: ACCOUNTS[userId], role: 'admin' } : null)
}
async function withLock(moduleLocks, fn) {
const app = await startApp((a) => {
a.use(locks.refuseMultipart)
a.use('/api/v1', locks.createLock(moduleLocks))
a.all('*', (req, res) => res.json({ reached: true }))
})
try {
return await fn((method, path, body, headers = {}) =>
fetch(`${app.url}/api/v1${path}`, {
method,
headers: body === undefined ? headers : { 'Content-Type': 'application/json', ...headers },
body: body === undefined ? undefined : JSON.stringify(body),
}))
} finally {
await app.close()
}
}
async function expectLocked(res) {
assert.equal(res.status, 403)
const body = await res.json()
assert.equal(body.code, 'DEMO_LOCKED')
assert.equal(body.message, 'This is turned off on the demo site.')
return body
}
async function expectReached(res) {
assert.equal(res.status, 200)
assert.deepEqual(await res.json(), { reached: true })
}
// ── The flag and the accounts ──────────────────────────────────────────────
test('only DEMO_MODE=true is the demo', () => {
for (const v of [undefined, '', '1', 'yes', 'TRUE', 'false']) {
if (v === undefined) delete process.env.DEMO_MODE
else process.env.DEMO_MODE = v
assert.equal(isDemo(), false, String(v))
}
process.env.DEMO_MODE = 'true'
assert.equal(isDemo(), true)
})
test('the four shared logins are recognised whatever their case', () => {
assert.deepEqual([...DEMO_ACCOUNTS], ['demo-admin', 'demo-moderator', 'demo-player', 'play-review'])
assert.equal(isDemoUsername('Demo-Admin'), true)
assert.equal(isDemoUsername('alice'), false)
assert.equal(isDemoUsername(null), false)
})
// ── The matcher ────────────────────────────────────────────────────────────
test('patterns match the way express routes: any case, optional trailing slash', () => {
const p = compile('/admin/users/:id')
assert.deepEqual(match(p, '/admin/users/7'), { id: '7' })
assert.deepEqual(match(p, '/Admin/USERS/7/'), { id: '7' })
assert.equal(match(p, '/admin/users'), null)
assert.equal(match(p, '/admin/users/7/mfa/reset'), null)
assert.deepEqual(match(compile('/a/:x'), '/a/b%20c'), { x: 'b c' })
})
test('a trailing * is the path and everything under it', () => {
const p = compile('/admin/modules/*')
for (const path of ['/admin/modules', '/admin/modules/', '/admin/modules/rust/enable', '/admin/modules/sources']) {
assert.ok(match(p, path), path)
}
assert.equal(match(p, '/admin/modulesx'), null)
})
test('a pattern that would compile to something else is refused', () => {
for (const bad of ['admin/x', '/admin//x', '/admin/*/x', '/admin/x y', '/admin/:']) {
assert.throws(() => compile(bad), /lock path/, bad)
}
})
// ── Core's list against the real routes ────────────────────────────────────
test("every row of core's list names a route the server actually serves", () => {
// A row that matched nothing would read as a lock and lock nothing. Held
// against routes.manifest.json, which `npm run routes:manifest` keeps exact.
const served = manifest.public
.filter((r) => r.path.startsWith('/api/v1/'))
.map((r) => ({ method: r.method, path: r.path.slice('/api/v1'.length) }))
for (const rule of locks.CORE_LOCKS) {
const hits = served.filter((r) => rule.methods.includes(r.method) && match(rule.compiled, r.path))
assert.ok(hits.length > 0, `${rule.methods.join('/')} ${rule.path} matches no served route`)
}
})
test('every upload route in the manifest is locked by name as well as by the multipart rule', () => {
const uploads = manifest.public.filter((r) => r.method === 'POST' && /upload|brand-asset/.test(r.path))
assert.ok(uploads.length >= 4)
for (const r of uploads) {
const path = r.path.slice('/api/v1'.length)
assert.ok(locks.CORE_LOCKS.some((l) => l.methods.includes('POST') && match(l.compiled, path)), path)
}
})
// ── Tier A, unconditional ──────────────────────────────────────────────────
test('the Tier A rows are refused, and reads of the same paths are not', async () => {
await withLock(undefined, async (req) => {
await expectLocked(await req('PUT', '/admin/site-mode', { mode: 'maintenance' }))
await expectLocked(await req('PUT', '/Admin/Site-Mode/', { mode: 'maintenance' }))
await expectLocked(await req('POST', '/admin/modules/rust/disable'))
await expectLocked(await req('DELETE', '/admin/modules/rust'))
await expectLocked(await req('POST', '/admin/modules/restart'))
await expectLocked(await req('POST', '/admin/auth/providers', { name: 'x' }))
await expectLocked(await req('PUT', '/admin/email/config', {}))
await expectLocked(await req('POST', '/admin/email/test'))
await expectLocked(await req('POST', '/admin/email/disconnect'))
await expectLocked(await req('PUT', '/admin/discord-bot/config', {}))
await expectLocked(await req('POST', '/admin/teams/voice/sync'))
await expectLocked(await req('POST', '/auth/password/forgot', { email: 'a@b.c' }))
await expectLocked(await req('POST', '/auth/password/reset/abc', { password: 'x' }))
await expectLocked(await req('POST', '/auth/register', { username: 'x' }))
await expectLocked(await req('POST', '/public/contact', { message: 'hi' }))
await expectLocked(await req('POST', '/admin/uploads'))
await expectReached(await req('GET', '/admin/site-mode'))
await expectReached(await req('GET', '/admin/modules'))
await expectReached(await req('GET', '/admin/email/config'))
await expectReached(await req('GET', '/auth/password/reset/abc'))
})
})
test('content, the rig and the rest of Tier C stay open', async () => {
await withLock(undefined, async (req) => {
await expectReached(await req('POST', '/admin/posts', { title: 'x' }))
await expectReached(await req('PUT', '/admin/wiki/guides', { title: 'x' }))
await expectReached(await req('POST', '/admin/events', {}))
await expectReached(await req('POST', '/admin/invites', {}))
await expectReached(await req('POST', '/admin/users', { username: 'bob' }))
await expectReached(await req('POST', '/auth/login', { username: 'demo-admin' }))
await expectReached(await req('POST', '/auth/mobile/logout', {}))
})
})
// ── Settings: open, except the keys that reach a Tier A lock ───────────────
test('PUT /admin/settings is open for theme and nav', async () => {
await withLock(undefined, async (req) => {
await expectReached(await req('PUT', '/admin/settings', { theme_visual: { preset: 'fantasy' }, nav_public: {} }))
await expectReached(await req('PUT', '/admin/settings', { player_registration: 'password' }))
})
})
// DM21: the hero editor's autosave and Preview write only the draft; Publish
// writes both, and the live key refuses the whole body.
test('the hero draft is open, publishing the hero is refused', async () => {
await withLock(undefined, async (req) => {
await expectReached(await req('PUT', '/admin/settings', { hero_layout_draft: '{"v":1}' }))
const body = await expectLocked(
await req('PUT', '/admin/settings', { hero_layout: '{"v":1}', hero_layout_draft: '{"v":1}' }),
)
assert.deepEqual(body.keys, ['hero_layout'])
})
})
test('PUT /admin/settings naming a protected key is refused whole, with the keys named', async () => {
await withLock(undefined, async (req) => {
for (const key of ['site_mode', 'site_mode_changed_by', 'module_source_hosts', 'teams_voice_enabled', 'demo_state']) {
const body = await expectLocked(await req('PUT', '/admin/settings', { theme_visual: {}, [key]: 'x' }))
assert.deepEqual(body.keys, [key])
}
})
})
// ── The shared logins ──────────────────────────────────────────────────────
test('an admin cannot change, delete or reset a shared login, but can anybody else', async () => {
stubUsers()
await withLock(undefined, async (req) => {
for (const id of [1, 2, 3, 4]) {
await expectLocked(await req('PUT', `/admin/users/${id}`, { role: 'player' }))
await expectLocked(await req('DELETE', `/admin/users/${id}`))
await expectLocked(await req('DELETE', `/admin/users/${id}/trusted-devices`))
await expectLocked(await req('DELETE', `/admin/users/${id}/trusted-devices/5`))
await expectLocked(await req('POST', `/admin/users/${id}/mfa/reset`))
}
await expectReached(await req('PUT', '/admin/users/9', { role: 'player' }))
await expectReached(await req('DELETE', '/admin/users/9'))
await expectReached(await req('PUT', '/admin/users/404', { role: 'player' }))
await expectReached(await req('PUT', '/admin/users/not-a-number', {}))
})
})
const SELF_ROWS = [
['PATCH', '/auth/me/account/username'],
['PATCH', '/auth/me/account/password'],
['PATCH', '/auth/me/account/email'],
['POST', '/auth/me/account/totp/setup'],
['POST', '/auth/me/account/totp/enable'],
['POST', '/auth/me/account/totp/disable'],
['POST', '/auth/me/account/recovery-codes/generate'],
['DELETE', '/auth/me/account/identities/steam'],
['DELETE', '/auth/me/sessions/12'], // DM17
]
test('signed in as a shared login, the account cannot be changed or other sessions revoked', async () => {
stubUsers()
signedInAs(4)
await withLock(undefined, async (req) => {
for (const [method, path] of SELF_ROWS) await expectLocked(await req(method, path, {}))
// Self-service that cannot lock anyone else out stays open.
await expectReached(await req('PUT', '/auth/me/notifications/channels', {}))
})
})
test('signed in as anyone else, or not at all, the same routes go to their handlers', async () => {
stubUsers()
signedInAs(9)
await withLock(undefined, async (req) => {
for (const [method, path] of SELF_ROWS) await expectReached(await req(method, path, {}))
})
signedInAs(null) // the route's own requireAuth answers 401
await withLock(undefined, async (req) => {
for (const [method, path] of SELF_ROWS) await expectReached(await req(method, path, {}))
})
})
test('a lock that cannot decide refuses', async () => {
users.getById = async () => {
throw new Error('pool down')
}
await withLock(undefined, async (req) => {
await expectLocked(await req('PUT', '/admin/users/9', {}))
})
})
// ── Uploads ────────────────────────────────────────────────────────────────
test('any multipart request is refused before a router sees it, wherever it is sent', async () => {
await withLock(undefined, async (req) => {
const multipart = { 'Content-Type': 'multipart/form-data; boundary=x' }
await expectLocked(await req('POST', '/admin/posts', undefined, multipart))
await expectLocked(await req('POST', '/rust/anything', undefined, { 'Content-Type': 'Multipart/Mixed; boundary=y' }))
await expectLocked(await req('GET', '/public/settings', undefined, multipart))
await expectReached(await req('POST', '/admin/posts', { title: 'json is fine' }))
})
})
// DM25: a data: URI in any string of a write's body is an upload too.
async function withDataUriLock(fn) {
const app = await startApp((a) => {
a.use(locks.refuseDataUri)
a.all('*', (req, res) => res.json({ reached: true }))
})
try {
return await fn((method, path, body) =>
fetch(`${app.url}/api/v1${path}`, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}))
} finally {
await app.close()
}
}
test('a data: URI anywhere in a write body is refused, on any route and any write method', async () => {
await withDataUriLock(async (req) => {
const img = '<p>hi</p><img src="data:image/png;base64,iVBORw0KGgo=">'
await expectLocked(await req('POST', '/admin/posts', { title: 't', body: img }))
await expectLocked(await req('PUT', '/admin/wiki/1', { content: img }))
await expectLocked(await req('PATCH', '/rust/anything', { deep: [{ x: { y: [img] } }] }))
await expectLocked(await req('PUT', '/admin/settings', { hero_layout_draft: JSON.stringify([{ src: 'data:image/webp;base64,UklG' }]) }))
await expectLocked(await req('DELETE', '/player/teams/x/forum/1', { reason: img }))
})
})
test('every data: URI shape a browser would load is refused', async () => {
const shapes = [
'DATA:image/png;base64,AAAA',
'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg"/>',
'data:text/html;base64,PHNjcmlwdD4=',
'data:,hello',
'data:;base64,AAAA',
'data: image/png;base64,AAAA',
'<div style="background:url(data:image/gif;base64,R0lG)">',
'<img src="&#100;ata:image/png;base64,AAAA">',
'<img src="&#x64;&#x61;&#x74;&#x61;&#x3a;image/png;base64,AAAA">',
'<img src="data&colon;image/png;base64,AAAA">',
'<img src="da\nta:image/png;base64,AAAA">',
'<img src="data:im\tage/png;base64,AAAA">',
]
for (const s of shapes) assert.equal(locks.hasDataUri({ body: s }), true, s)
})
test('prose that mentions data, reads, and bodies without strings pass', async () => {
for (const s of ['metadata: none', 'Data: none, yet', 'the data: it is fine', 'see https://example.com/data:x', 'mydata:image/png,x']) {
assert.equal(locks.hasDataUri({ body: s }), false, s)
}
assert.equal(locks.hasDataUri(undefined), false)
assert.equal(locks.hasDataUri({ n: 1, ok: true, list: [null, 2] }), false)
await withDataUriLock(async (req) => {
await expectReached(await req('POST', '/admin/posts', { title: 'json is fine', body: '<p>metadata: none</p>' }))
})
// A read is never refused, whatever it carries.
const app = await startApp((a) => {
a.use(locks.refuseDataUri)
a.all('*', (req, res) => res.json({ reached: true }))
})
try {
await expectReached(await fetch(`${app.url}/api/v1/public/settings?x=data:image/png;base64,AAAA`))
} finally {
await app.close()
}
})
// ── Module locks ───────────────────────────────────────────────────────────
test("a module's declared locks are refused alongside core's", async () => {
const declared = [{ methods: ['PUT'], path: '/admin/rust/servers/:id', compiled: compile('/admin/rust/servers/:id'), source: 'rust' }]
await withLock(() => declared, async (req) => {
await expectLocked(await req('PUT', '/admin/rust/servers/demo', {}))
await expectReached(await req('GET', '/admin/rust/servers/demo'))
await expectReached(await req('DELETE', '/admin/rust/servers/demo'))
await expectReached(await req('POST', '/admin/rust/servers/demo/test'))
})
})
// ── Search engines ─────────────────────────────────────────────────────────
test('noindex and robots keep the demo out of search engines', async () => {
const app = await startApp((a) => {
a.use(locks.noindex)
a.get('/robots.txt', locks.robots)
a.get('/', (req, res) => res.send('ok'))
})
try {
const page = await fetch(`${app.url}/`)
assert.equal(page.headers.get('x-robots-tag'), 'noindex, nofollow')
const robots = await fetch(`${app.url}/robots.txt`)
assert.equal(await robots.text(), 'User-agent: *\nDisallow: /\n')
} finally {
await app.close()
}
})

View File

@@ -0,0 +1,229 @@
// ── The demo's module contract (MODULE_API 1.18.0) and the seed's helpers ──
//
// `demoLocks` in module.json, `ctx.demoMode`, `api.onDemoSeed` (DM15), the
// `demo` block on GET /public/settings, and the pure parts of `seed:demo`.
// Each loader test builds a throwaway modules directory and re-requires the
// loader, the way moduleLoader.test.js does and for the same reason.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const fs = require('fs')
const os = require('os')
const path = require('path')
const { test, afterEach, after } = require('node:test')
const assert = require('node:assert/strict')
const express = require('express')
const db = require('../src/utils/db')
const registries = require('../src/modules/registries')
const settingsDb = require('../src/model/settings/settings.db')
const settings = require('../src/model/settings/settings.model')
const { resolveEnv, nextResetAt, checkFixture, eventActionIds } = require('../db/seedDemo')
after(() => db.close())
afterEach(() => {
delete process.env.DEMO_MODE
})
const emptyTiers = () => ({ public: express.Router(), admin: express.Router(), player: express.Router() })
function loadOne(manifest, server = 'module.exports = (ctx, api) => { const r = ctx.express.Router(); api.registerRoutes({ admin: { "/rust": r } }) }') {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-demo-'))
const dir = path.join(root, 'rust')
fs.mkdirSync(dir)
fs.writeFileSync(path.join(dir, 'module.json'), JSON.stringify({
id: 'rust',
name: 'Rust',
version: '1.0.0',
coreApi: '^1.18.0',
server: 'index.js',
mounts: { admin: ['/rust'] },
...manifest,
}))
fs.writeFileSync(path.join(dir, 'index.js'), server)
process.env.MODULES_DIR = root
registries._reset()
delete require.cache[require.resolve('../src/modules/loader')]
// eslint-disable-next-line global-require
const loader = require('../src/modules/loader')
loader.load(emptyTiers())
return loader
}
const stateOf = (loader) => loader.list().find((m) => m.id === 'rust')
// ── demoLocks ──────────────────────────────────────────────────────────────
test('declared demoLocks load and come back compiled', () => {
const loader = loadOne({
demoLocks: [
{ method: 'PUT', path: '/admin/rust/servers/:id' },
{ method: 'DELETE', path: '/admin/rust/servers/:id' },
],
})
assert.equal(stateOf(loader).state, 'registered')
const got = loader.demoLocks()
assert.deepEqual(got.map((l) => [l.methods, l.path, l.source]), [
[['PUT'], '/admin/rust/servers/:id', 'rust'],
[['DELETE'], '/admin/rust/servers/:id', 'rust'],
])
assert.ok(got[0].compiled.re.test('/admin/rust/servers/demo'))
})
test('a module without demoLocks declares none', () => {
assert.deepEqual(loadOne({}).demoLocks(), [])
})
test('a bad demoLocks entry fails the module at load, demo or not', () => {
const cases = [
[{ demoLocks: {} }, /demoLocks must be an array/],
[{ demoLocks: ['PUT /x'] }, /must be an object/],
[{ demoLocks: [{ method: 'GET', path: '/admin/rust/x' }] }, /must be one of POST, PUT, PATCH, DELETE/],
[{ demoLocks: [{ method: 'PUT', path: '/admin/rust/x', why: 'y' }] }, /unknown key "demoLocks.why"/],
[{ demoLocks: [{ method: 'PUT', path: '/admin/rust/*/x' }] }, /\* must be the last segment/],
// Outside the module's own mounts: a module locks its routes and nobody else's.
[{ demoLocks: [{ method: 'PUT', path: '/admin/site-mode' }] }, /outside the module's mounts/],
[{ demoLocks: [{ method: 'PUT', path: '/public/rust/x' }] }, /outside the module's mounts/],
[{ demoLocks: [{ method: 'PUT', path: '/admin/*' }] }, /outside the module's mounts/],
]
for (const [manifest, reason] of cases) {
const loader = loadOne(manifest)
const m = stateOf(loader)
assert.equal(m.state, 'startup_failed', JSON.stringify(manifest))
assert.match(m.reason, reason)
}
})
test('a module declaring ^1.18.0 and an empty demoLocks loads on this core', () => {
const loader = loadOne({ coreApi: '^1.18.0', demoLocks: [] })
assert.equal(stateOf(loader).state, 'registered')
})
// ── ctx.demoMode ───────────────────────────────────────────────────────────
const PROBE = `module.exports = (ctx, api) => {
global.__demoProbe = ctx.demoMode
api.registerRoutes({ admin: { '/rust': ctx.express.Router() } })
}`
test('ctx.demoMode is false on a normal deployment and true on the demo', () => {
loadOne({}, PROBE)
assert.equal(global.__demoProbe, false)
process.env.DEMO_MODE = 'true'
loadOne({}, PROBE)
assert.equal(global.__demoProbe, true)
delete global.__demoProbe
})
// ── onDemoSeed ─────────────────────────────────────────────────────────────
test('onDemoSeed is collected for the seed and handed the fixture and account ids', async () => {
const loader = loadOne({}, `module.exports = (ctx, api) => {
api.registerRoutes({ admin: { '/rust': ctx.express.Router() } })
api.onDemoSeed(async (data, { accounts }) => { global.__demoSeed = { data, accounts } })
}`)
const hooks = loader.demoSeedHooks()
assert.deepEqual(hooks.map((h) => h.id), ['rust'])
await hooks[0].hook({ servers: [] }, { accounts: { 'demo-player': 3 } })
assert.deepEqual(global.__demoSeed, { data: { servers: [] }, accounts: { 'demo-player': 3 } })
delete global.__demoSeed
})
test('onDemoSeed twice is a registration error, like every other hook', () => {
const loader = loadOne({}, `module.exports = (ctx, api) => {
api.registerRoutes({ admin: { '/rust': ctx.express.Router() } })
api.onDemoSeed(() => {})
api.onDemoSeed(() => {})
}`)
assert.equal(stateOf(loader).state, 'startup_failed')
assert.match(stateOf(loader).reason, /onDemoSeed\(\) called twice/)
})
// ── GET /public/settings ───────────────────────────────────────────────────
const originalGetAll = settingsDb.getAll
afterEach(() => {
settingsDb.getAll = originalGetAll
})
test('the demo block is present only on the demo, and read forgivingly', async () => {
const state = {
resetsAt: '2026-10-24T14:00:00.000Z',
logins: [{ username: 'demo-admin', password: 'demo-admin-pass', role: 'admin' }, { username: 7 }],
}
settingsDb.getAll = async () => [{ key: 'demo_state', value: JSON.stringify(state) }]
const normal = await settings.getPublic()
assert.equal('demo' in normal, false)
process.env.DEMO_MODE = 'true'
const demo = await settings.getPublic()
assert.deepEqual(demo.demo, {
resetsAt: '2026-10-24T14:00:00.000Z',
logins: [{ username: 'demo-admin', password: 'demo-admin-pass', role: 'admin' }],
})
settingsDb.getAll = async () => [{ key: 'demo_state', value: '{broken' }]
assert.deepEqual((await settings.getPublic()).demo, { resetsAt: null, logins: [] })
})
// ── seed:demo's pure helpers ───────────────────────────────────────────────
test('resolveEnv replaces $env: strings at any depth and refuses an unset one', () => {
const env = { DEMO_RUST_SIDECAR_TOKEN: 'tok', DEMO_RUST_SIDECAR_URL: 'http://rig:7790' }
assert.deepEqual(
resolveEnv({ a: '$env:DEMO_RUST_SIDECAR_URL', b: [{ t: '$env:DEMO_RUST_SIDECAR_TOKEN' }], c: 'plain $env:X', d: 3 }, env),
{ a: 'http://rig:7790', b: [{ t: 'tok' }], c: 'plain $env:X', d: 3 },
)
assert.throws(() => resolveEnv({ m: { t: '$env:NOPE' } }, env), /fixture\.m\.t: environment variable NOPE is not set/)
assert.throws(() => resolveEnv('$env:EMPTY', { EMPTY: '' }), /EMPTY is not set/)
})
test('nextResetAt is the next two-hour slot on the hour, UTC', () => {
const at = (iso) => nextResetAt(new Date(iso), 2).toISOString()
assert.equal(at('2026-10-24T14:02:10Z'), '2026-10-24T16:00:00.000Z')
assert.equal(at('2026-10-24T13:59:59Z'), '2026-10-24T14:00:00.000Z')
assert.equal(at('2026-10-24T14:00:00Z'), '2026-10-24T16:00:00.000Z')
assert.equal(at('2026-10-24T23:30:00Z'), '2026-10-25T00:00:00.000Z')
assert.equal(nextResetAt(new Date('2026-10-24T14:02:00Z'), 3).toISOString(), '2026-10-24T15:00:00.000Z')
})
const GOOD = {
accounts: {
'demo-admin': { password: 'demo-admin-pass', role: 'admin' },
'demo-moderator': { password: 'demo-moderator-pass', role: 'moderator' },
'demo-player': { password: 'demo-player-pass', role: 'player' },
'play-review': { password: 'play-review-pass', role: 'admin' },
},
}
test('checkFixture accepts the four accounts and refuses anything that would leave the lock wrong', () => {
assert.deepEqual(checkFixture(GOOD), [])
const { 'play-review': _gone, ...three } = GOOD.accounts
assert.match(checkFixture({ accounts: three }).join('\n'), /accounts\.play-review is missing/)
assert.match(checkFixture({ accounts: { ...GOOD.accounts, alice: { password: 'alicealice', role: 'admin' } } }).join('\n'),
/accounts\.alice is not a demo account/)
assert.match(checkFixture({ accounts: { ...GOOD.accounts, 'demo-admin': { password: 'short', role: 'admin' } } }).join('\n'),
/at least 8 characters/)
assert.match(checkFixture({ accounts: { ...GOOD.accounts, 'demo-admin': { password: 'longenough', role: 'root' } } }).join('\n'),
/role must be one of/)
assert.match(checkFixture({ ...GOOD, settings: { demo_state: '{}' } }).join('\n'), /written by the seed itself/)
assert.match(checkFixture({ ...GOOD, content: { posts: {} } }).join('\n'), /content\.posts must be an array/)
assert.match(checkFixture({ ...GOOD, modules: [] }).join('\n'), /modules must be an object/)
})
test('eventActionIds lists each action the seeded events use, once, and the shipped fixture names all three', () => {
const step = (actionId) => ({ actionId, params: {} })
const ev = (...phases) => ({ spec: { phases: phases.map((steps) => ({ steps: steps.map(step) })) } })
assert.deepEqual(eventActionIds([ev(['a.run', 'core.wait'], ['a.run']), ev(['b.pay']), {}, { spec: {} }]),
['a.run', 'core.wait', 'b.pay'])
assert.deepEqual(eventActionIds(), [])
const fixture = require('../../deploy/demo/seed.json') // eslint-disable-line global-require
assert.deepEqual(eventActionIds(fixture.content.events), ['rust.command.run', 'rust.dt.start', 'rust.coins.pay'])
})
test('seed:demo refuses to run off the demo', async () => {
const { run } = require('../db/seedDemo') // eslint-disable-line global-require
await assert.rejects(() => run('anything.json'), /only with DEMO_MODE=true/)
})

View File

@@ -0,0 +1,154 @@
// ── The demo refuses a step at save (MODULE_API 1.19.0, DEMO_MODE.md §3.4) ──
//
// DM18: an event action may declare `demoRefuses(params)`. With DEMO_MODE on,
// the definition save path asks it for every step at create, save and publish,
// and a sentence back refuses the write `403 DEMO_LOCKED`. Off the demo it is
// never called. The definitions table is stubbed on the module object the model
// holds; the pool points at a closed port.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, beforeEach, afterEach, after } = require('node:test')
const assert = require('node:assert/strict')
const db = require('../src/utils/db')
const registries = require('../src/modules/registries')
const definitions = require('../src/model/events/eventDefinitions.model')
const definitionsDb = require('../src/model/events/eventDefinitions.db')
const controller = require('../src/router/v1/admin/events.controller')
after(() => db.close())
let asked
const register = (extra = {}) => {
const api = registries.stage('demo')
api.registerEventActions([{
id: 'demo.console.run',
label: 'Run console commands',
risk: 'irreversible',
reversible: 'none',
params: [{ name: 'command', type: 'string', required: true, example: 'say hi' }],
perform: async () => ({ ok: true }),
demoRefuses: (params) => {
asked.push(params)
return params.command === 'quit' ? '"quit" is turned off on the demo site.' : null
},
...extra,
}])
registries.apply(api.staged)
}
const input = (command) => ({
title: 'Console night',
spec: {
schedule: { kind: 'manual' },
phases: [{ key: 'main', label: 'Main', steps: [{ actionId: 'demo.console.run', params: { command } }] }],
},
})
const stubTable = (t, existing = null) => {
const wrote = []
t.mock.method(definitionsDb, 'list', async () => [])
t.mock.method(definitionsDb, 'insert', async (row) => { wrote.push(row); return 7 })
t.mock.method(definitionsDb, 'update', async (_id, row) => { wrote.push(row) })
t.mock.method(definitionsDb, 'getById', async () => existing || { id: 7, state: 'draft', title: 'Console night', spec: input('say hi').spec })
return wrote
}
beforeEach(() => {
asked = []
registries._reset()
registries.registerCore()
})
afterEach(() => {
registries._reset()
delete process.env.DEMO_MODE
})
test('demoRefuses must be a function, and never rides out to the catalog', () => {
assert.throws(() => register({ demoRefuses: 'quit' }), /demoRefuses must be a function/)
registries._reset()
register()
assert.equal(typeof registries.eventAction('demo.console.run').demoRefuses, 'function')
const served = registries.allEventActions().find((a) => a.id === 'demo.console.run')
assert.equal('demoRefuses' in served, false)
})
test('off the demo the hook is never asked, and a denied command saves', async (t) => {
register()
const wrote = stubTable(t)
const made = await definitions.create(input('quit'), 1, { role: 'admin' })
assert.equal(made.ok, true)
assert.equal(wrote.length, 1)
assert.deepEqual(asked, [])
})
test('on the demo a create naming a denied command is refused 403 DEMO_LOCKED and writes nothing', async (t) => {
process.env.DEMO_MODE = 'true'
register()
const wrote = stubTable(t)
const made = await definitions.create(input('quit'), 1, { role: 'admin' })
assert.deepEqual(made, {
ok: false,
status: 403,
code: 'DEMO_LOCKED',
message: '"quit" is turned off on the demo site.',
errors: ['"quit" is turned off on the demo site.'],
})
assert.equal(wrote.length, 0)
// The hook is handed the step's checked params.
assert.deepEqual(asked, [{ command: 'quit' }])
})
test('on the demo a command the hook allows saves', async (t) => {
process.env.DEMO_MODE = 'true'
register()
const wrote = stubTable(t)
assert.equal((await definitions.create(input('say hi'), 1, { role: 'admin' })).ok, true)
assert.equal((await definitions.save(7, input('say hi'), 1, { role: 'admin' })).ok, true)
assert.equal(wrote.length, 2)
})
test('on the demo a save and a publish are refused too', async (t) => {
process.env.DEMO_MODE = 'true'
register()
const wrote = stubTable(t, { id: 7, state: 'draft', title: 'Console night', spec: input('quit').spec })
const saved = await definitions.save(7, input('quit'), 1, { role: 'admin' })
assert.equal(saved.status, 403)
assert.equal(saved.code, 'DEMO_LOCKED')
// A draft written before the list grew: publish asks again.
const published = await definitions.publish(7, 1)
assert.equal(published.status, 403)
assert.equal(published.code, 'DEMO_LOCKED')
assert.equal(wrote.length, 0)
})
test('a hook that throws refuses with core\'s sentence (fails closed)', async (t) => {
process.env.DEMO_MODE = 'true'
register({ demoRefuses: () => { throw new Error('boom') } })
stubTable(t)
const made = await definitions.create(input('say hi'), 1, { role: 'admin' })
assert.equal(made.status, 403)
assert.equal(made.code, 'DEMO_LOCKED')
assert.match(made.message, /"Run console commands" could not be checked/)
})
test('the controller answers the refusal with code and message, so the toast fires', async (t) => {
process.env.DEMO_MODE = 'true'
register()
stubTable(t)
let status
let body
const res = {
status(s) { status = s; return this },
json(b) { body = b; return this },
}
await controller.create({ body: input('quit'), user: { id: 1, role: 'admin' } }, res)
assert.equal(status, 403)
assert.deepEqual(body, {
errors: ['"quit" is turned off on the demo site.'],
code: 'DEMO_LOCKED',
message: '"quit" is turned off on the demo site.',
})
})

View File

@@ -112,7 +112,7 @@ test('the version a module declares against satisfies ^1.10.0', () => {
// 1.10.0 loading — Module-uo declares exactly `^1.10.0` — and this is the
// assertion that says it does. The number itself is pinned in version.js.
assert.ok(semver.satisfies(MODULE_API_VERSION, '^1.10.0'), MODULE_API_VERSION)
assert.equal(MODULE_API_VERSION, '1.17.0')
assert.equal(MODULE_API_VERSION, '1.19.0')
})
test('a module registers actions, budgets, leases and option sources', () => {

View File

@@ -550,8 +550,10 @@ test('ctx exposes exactly the documented surface, and is frozen', () => {
// API 1.17.0 added `identities` (STEAM_SIGNIN.md §4): READ only, one provider
// at a time. Linking stays core's, and a module takes part through the two
// hooks on `api`, never by writing an identity itself.
// API 1.18.0 added `demoMode` (DEMO_MODE.md §3.4): a boolean, for the locks
// the module enforces in its own handlers.
assert.deepEqual(probe.keys, [
'activity', 'auth', 'db', 'events', 'express', 'identities', 'inbox', 'log', 'middleware', 'moduleId', 'paths',
'activity', 'auth', 'db', 'demoMode', 'events', 'express', 'identities', 'inbox', 'log', 'middleware', 'moduleId', 'paths',
'posts', 'push', 'secretBox', 'settings', 'site', 'teams', 'uploads', 'users', 'validator',
])
// is core's limiter FACTORY, not a limiter: a module states its own